feat: Add Tier 2 items, icons, and market support

This commit is contained in:
Matthew Mone 2026-01-14 11:11:02 -08:00
parent b0ef4f30a9
commit 25c9d47587
44 changed files with 322 additions and 43 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

View file

@ -11,7 +11,10 @@ export class Item {
this.name = def.name; this.name = def.name;
this.type = def.type; // WEAPON, ARMOR, UTILITY, RELIC this.type = def.type; // WEAPON, ARMOR, UTILITY, RELIC
this.rarity = def.rarity || "COMMON"; this.rarity = def.rarity || "COMMON";
this.rarity = def.rarity || "COMMON";
this.tags = def.tags || []; this.tags = def.tags || [];
this.icon = def.icon || null;
this.description = def.description || "";
// Base Stats (e.g. { attack: 5, defense: 2 }) // Base Stats (e.g. { attack: 5, defense: 2 })
this.stats = def.stats || {}; this.stats = def.stats || {};

103
src/items/tier2_gear.json Normal file
View file

@ -0,0 +1,103 @@
[
{
"id": "ITEM_STEEL_LONGSWORD",
"name": "Steel Longsword",
"type": "WEAPON",
"rarity": "UNCOMMON",
"tags": ["PHYSICAL", "MELEE"],
"stats": {
"attack": 6,
"accuracy": 5
},
"description": "A finely forged blade, balanced and sharp.",
"icon": "assets/icons/items/item_steel_longsword.png"
},
{
"id": "ITEM_REINFORCED_PLATE",
"name": "Reinforced Plate",
"type": "ARMOR",
"rarity": "UNCOMMON",
"tags": ["HEAVY"],
"stats": {
"defense": 6,
"health": 10,
"speed": -2
},
"description": "Heavy steel plates reinforced with chainmail.",
"icon": "assets/icons/items/item_reinforced_plate.png"
},
{
"id": "ITEM_CRYSTAL_STAFF",
"name": "Crystal Staff",
"type": "WEAPON",
"rarity": "UNCOMMON",
"tags": ["MAGIC", "RANGED", "TWO_HANDED"],
"stats": {
"magic": 8,
"willpower": 3
},
"description": "Embedded with a large mana crystal for focusing power.",
"icon": "assets/icons/items/item_crystal_staff.png"
},
{
"id": "ITEM_SILK_WEAVE_ROBES",
"name": "Silk Weave Robes",
"type": "ARMOR",
"rarity": "UNCOMMON",
"tags": ["LIGHT", "MAGIC"],
"stats": {
"willpower": 6,
"magic": 3,
"speed": 1
},
"description": "Enchanted silk that offers protection without weight.",
"icon": "assets/icons/items/item_silk_weave_robes.png"
},
{
"id": "ITEM_SERRATED_DAGGER",
"name": "Serrated Dagger",
"type": "WEAPON",
"rarity": "UNCOMMON",
"tags": ["PHYSICAL", "MELEE", "LIGHT"],
"stats": {
"attack": 5,
"crit_chance": 10,
"speed": 2
},
"description": "Designed to inflict bleeding wounds.",
"icon": "assets/icons/items/item_serrated_dagger.png"
},
{
"id": "ITEM_AMULET_VITALITY",
"name": "Amulet of Vitality",
"type": "ACCESSORY",
"rarity": "RARE",
"tags": ["MAGIC"],
"stats": {
"health": 20,
"regen": 2
},
"description": "Radiates a warm, life-giving energy.",
"icon": "assets/icons/items/item_amulet_vitality.png"
},
{
"id": "ITEM_ADVANCED_TOOLKIT",
"name": "Advanced Toolkit",
"type": "UTILITY",
"rarity": "UNCOMMON",
"tags": ["TECH"],
"stats": {
"tech": 5
},
"passive_effects": [
{
"trigger": "ON_INTERACT",
"condition": "mechanical",
"action": "REPAIR",
"value": 10
}
],
"description": "Contains precision tools for complex machinery.",
"icon": "assets/icons/items/item_advanced_toolkit.png"
}
]

View file

@ -35,9 +35,14 @@ export class ItemRegistry {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async _doLoadAll() { async _doLoadAll() {
// Lazy-load tier1_gear.json // Lazy-load item definitions
const tier1Gear = await import("../items/tier1_gear.json", { with: { type: "json" } }).then(m => m.default); const tier1Gear = await import("../items/tier1_gear.json", {
with: { type: "json" },
}).then((m) => m.default);
const tier2Gear = await import("../items/tier2_gear.json", {
with: { type: "json" },
}).then((m) => m.default);
// Load tier1_gear.json // Load tier1_gear.json
for (const itemDef of tier1Gear) { for (const itemDef of tier1Gear) {
if (itemDef && itemDef.id) { if (itemDef && itemDef.id) {
@ -46,6 +51,14 @@ export class ItemRegistry {
} }
} }
// Load tier2_gear.json
for (const itemDef of tier2Gear) {
if (itemDef && itemDef.id) {
const item = new Item(itemDef);
this.items.set(itemDef.id, item);
}
}
console.log(`Loaded ${this.items.size} items`); console.log(`Loaded ${this.items.size} items`);
} }
@ -69,4 +82,3 @@ export class ItemRegistry {
// Export singleton instance // Export singleton instance
export const itemRegistry = new ItemRegistry(); export const itemRegistry = new ItemRegistry();

View file

@ -78,7 +78,6 @@ export class MarketManager {
this.marketState = { this.marketState = {
generationId: `INIT_${Date.now()}`, generationId: `INIT_${Date.now()}`,
stock: [], stock: [],
buyback: [],
}; };
await this.generateStock(1); await this.generateStock(1);
} }
@ -130,20 +129,26 @@ export class MarketManager {
const newStock = []; const newStock = [];
if (tier === 1) { if (tier === 1) {
// Tier 1: Smith (5 Common Weapons, 3 Common Armor) // Tier 1: Weapons, Armor, and basic Utility
const smithWeapons = this._generateMerchantStock( const weapons = this._generateMerchantStock(
allItems, allItems,
["WEAPON"], ["WEAPON"],
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 }, { COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
5 5
); );
const smithArmor = this._generateMerchantStock( const armor = this._generateMerchantStock(
allItems, allItems,
["ARMOR"], ["ARMOR"],
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 }, { COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
3 3
); );
newStock.push(...smithWeapons, ...smithArmor); const utility = this._generateMerchantStock(
allItems,
["UTILITY"],
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
2
);
newStock.push(...weapons, ...armor, ...utility);
// Alchemist (5 Potions, 2 Grenades) - simplified for now since we don't have potions in tier1_gear // Alchemist (5 Potions, 2 Grenades) - simplified for now since we don't have potions in tier1_gear
// Will add when consumables are available // Will add when consumables are available
@ -182,8 +187,7 @@ export class MarketManager {
const stockWithIds = newStock.map((item, index) => { const stockWithIds = newStock.map((item, index) => {
const itemDef = this.itemRegistry.get(item.defId); const itemDef = this.itemRegistry.get(item.defId);
const basePrice = this._calculateBasePrice(itemDef); const basePrice = this._calculateBasePrice(itemDef);
const variance = 1 + (Math.random() * 0.2 - 0.1); // ±10% variance const price = basePrice;
const price = Math.floor(basePrice * variance);
return { return {
id: `STOCK_${Date.now()}_${index}`, id: `STOCK_${Date.now()}_${index}`,
@ -295,10 +299,9 @@ export class MarketManager {
*/ */
async buyItem(stockId) { async buyItem(stockId) {
// Check both stock and buyback // Check both stock and buyback
let marketItem = this.marketState.stock.find((item) => item.id === stockId); const marketItem = this.marketState.stock.find(
if (!marketItem) { (item) => item.id === stockId
marketItem = this.marketState.buyback.find((item) => item.id === stockId); );
}
if (!marketItem || marketItem.purchased) { if (!marketItem || marketItem.purchased) {
return false; return false;
} }
@ -402,23 +405,21 @@ export class MarketManager {
}) })
); );
// 6. Create buyback entry (limit 10) // 6. Add to Market Stock (at full price)
if (this.marketState.buyback.length >= 10) { const marketItem = {
this.marketState.buyback.shift(); // Remove oldest id: `STOCK_${Date.now()}_SOLD_${Math.random()
} .toString(36)
.substr(2, 5)}`,
const buybackItem = {
id: `BUYBACK_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
defId: itemInstance.defId, defId: itemInstance.defId,
type: itemDef.type, type: itemDef.type,
rarity: itemDef.rarity, rarity: itemDef.rarity,
price: sellPrice, // Buyback price = sell price price: basePrice, // Resell at full market value
discount: 0, discount: 0,
purchased: false, purchased: false,
instanceData: { ...itemInstance }, // Store copy of original instance instanceData: { ...itemInstance }, // Store copy of original instance
}; };
this.marketState.buyback.push(buybackItem); this.marketState.stock.push(marketItem);
// 7. Save market state // 7. Save market state
await this.persistence.saveMarketState(this.marketState); await this.persistence.saveMarketState(this.marketState);
@ -445,15 +446,16 @@ export class MarketManager {
*/ */
getStockForMerchant(merchantType) { getStockForMerchant(merchantType) {
if (merchantType === "BUYBACK") { if (merchantType === "BUYBACK") {
return this.marketState.buyback; return [];
} }
// Filter stock by merchant type // Filter stock by merchant type
const typeMap = { const typeMap = {
SMITH: ["WEAPON", "ARMOR"], WEAPONS: ["WEAPON"],
TAILOR: ["ARMOR"], ARMOR: ["ARMOR"],
ALCHEMIST: ["CONSUMABLE", "UTILITY"], ENGINEER: ["UTILITY"],
SCAVENGER: ["RELIC", "UTILITY"], ALCHEMIST: ["CONSUMABLE"],
BAZAAR: ["RELIC"],
}; };
const allowedTypes = typeMap[merchantType] || []; const allowedTypes = typeMap[merchantType] || [];
@ -462,6 +464,39 @@ export class MarketManager {
); );
} }
/**
* Gets sellable items from player's hub stash.
* @returns {MarketItem[]}
*/
getSellableInventory() {
if (!this.inventoryManager?.hubStash) return [];
// Get all items from stash
const items = this.inventoryManager.hubStash.items || [];
// Format as MarketItems
return items
.map((itemInstance) => {
const itemDef = this.itemRegistry.get(itemInstance.defId);
if (!itemDef) return null;
const basePrice = this._calculateBasePrice(itemDef);
const sellPrice = Math.floor(basePrice * 0.25);
return {
id: itemInstance.uid, // Use UID for selling
defId: itemInstance.defId,
type: itemDef.type,
rarity: itemDef.rarity,
price: sellPrice,
discount: 0,
purchased: false, // Not relevant for selling, but keeps shape
isSellable: true, // Flag for UI
};
})
.filter((item) => item !== null);
}
/** /**
* Cleanup - remove event listeners. * Cleanup - remove event listeners.
*/ */

View file

@ -207,6 +207,13 @@ export class MarketplaceScreen extends LitElement {
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.item-icon {
width: 80px;
height: 80px;
object-fit: contain;
margin-bottom: var(--spacing-sm);
}
.item-type { .item-type {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
color: var(--color-text-secondary); color: var(--color-text-secondary);
@ -332,7 +339,7 @@ export class MarketplaceScreen extends LitElement {
super(); super();
this.marketManager = null; this.marketManager = null;
this.wallet = { aetherShards: 0, ancientCores: 0 }; this.wallet = { aetherShards: 0, ancientCores: 0 };
this.activeMerchant = "SMITH"; this.activeMerchant = "WEAPONS";
this.activeFilter = "ALL"; this.activeFilter = "ALL";
this.selectedItem = null; this.selectedItem = null;
this.showModal = false; this.showModal = false;
@ -366,13 +373,48 @@ export class MarketplaceScreen extends LitElement {
_getStock() { _getStock() {
if (!this.marketManager) return []; if (!this.marketManager) return [];
const stock = this.marketManager.getStockForMerchant(this.activeMerchant);
// Apply filter let stock = [];
if (this.activeFilter === "ALL") { if (this.activeMerchant === "SELL") {
return stock; stock = this.marketManager.getSellableInventory();
} else {
stock = this.marketManager.getStockForMerchant(this.activeMerchant);
} }
return stock.filter((item) => item.type === this.activeFilter);
// Filter out purchased items and apply type filter
let filteredStock = stock.filter((item) => !item.purchased);
if (this.activeFilter !== "ALL") {
filteredStock = filteredStock.filter(
(item) => item.type === this.activeFilter
);
}
// Sort: Type -> Rarity -> Name -> Price
const rarityWeight = {
ANCIENT: 4,
RARE: 3,
UNCOMMON: 2,
COMMON: 1,
};
return filteredStock.sort((a, b) => {
// 1. Type
if (a.type !== b.type) return a.type.localeCompare(b.type);
// 2. Rarity (High to Low)
const rarityA = rarityWeight[a.rarity] || 0;
const rarityB = rarityWeight[b.rarity] || 0;
if (rarityA !== rarityB) return rarityB - rarityA;
// 3. Name
const nameA = this._getItemName(a);
const nameB = this._getItemName(b);
if (nameA !== nameB) return nameA.localeCompare(nameB);
// 4. Price
return a.price - b.price;
});
} }
_onMerchantClick(merchant) { _onMerchantClick(merchant) {
@ -402,6 +444,11 @@ export class MarketplaceScreen extends LitElement {
async _confirmBuy() { async _confirmBuy() {
if (!this.selectedItem || !this.marketManager) return; if (!this.selectedItem || !this.marketManager) return;
if (this.activeMerchant === "SELL") {
await this._confirmSell();
return;
}
const success = await this.marketManager.buyItem(this.selectedItem.id); const success = await this.marketManager.buyItem(this.selectedItem.id);
if (success) { if (success) {
this._updateWallet(); this._updateWallet();
@ -412,6 +459,19 @@ export class MarketplaceScreen extends LitElement {
} }
} }
async _confirmSell() {
if (!this.selectedItem || !this.marketManager) return;
const success = await this.marketManager.sellItem(this.selectedItem.id);
if (success) {
this._updateWallet();
this._closeModal();
this.requestUpdate();
} else {
alert("Sell failed.");
}
}
_canAfford(item) { _canAfford(item) {
return this.wallet.aetherShards >= item.price; return this.wallet.aetherShards >= item.price;
} }
@ -429,6 +489,30 @@ export class MarketplaceScreen extends LitElement {
return item.defId; return item.defId;
} }
_getItemIcon(item) {
if (this.marketManager?.itemRegistry) {
const itemDef = this.marketManager.itemRegistry.get(item.defId);
return itemDef?.icon || null;
}
return null;
}
_getItemDescription(item) {
if (this.marketManager?.itemRegistry) {
const itemDef = this.marketManager.itemRegistry.get(item.defId);
return itemDef?.description || "No description available.";
}
return "";
}
_getItemStats(item) {
if (this.marketManager?.itemRegistry) {
const itemDef = this.marketManager.itemRegistry.get(item.defId);
return itemDef?.stats || {};
}
return {};
}
_dispatchClose() { _dispatchClose() {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("market-closed", { new CustomEvent("market-closed", {
@ -441,10 +525,12 @@ export class MarketplaceScreen extends LitElement {
render() { render() {
const stock = this._getStock(); const stock = this._getStock();
const merchants = [ const merchants = [
{ id: "SMITH", icon: "⚔️", label: "Smith" }, { id: "WEAPONS", icon: "⚔️", label: "Weapons" },
{ id: "TAILOR", icon: "🧥", label: "Tailor" }, { id: "ARMOR", icon: "🛡️", label: "Armor" },
{ id: "ENGINEER", icon: "🔧", label: "Engineer" },
{ id: "ALCHEMIST", icon: "⚗️", label: "Alchemist" }, { id: "ALCHEMIST", icon: "⚗️", label: "Alchemist" },
{ id: "BUYBACK", icon: "♻️", label: "Buyback" }, { id: "BAZAAR", icon: "🏺", label: "Bazaar" },
{ id: "SELL", icon: "💰", label: "Sell Items" },
]; ];
const filters = [ const filters = [
@ -517,6 +603,13 @@ export class MarketplaceScreen extends LitElement {
${item.purchased ${item.purchased
? html`<div class="sold-overlay">SOLD</div>` ? html`<div class="sold-overlay">SOLD</div>`
: ""} : ""}
${this._getItemIcon(item)
? html`<img
src="${this._getItemIcon(item)}"
class="item-icon"
alt="${this._getItemName(item)}"
/>`
: html`<div class="item-icon">📦</div>`}
<div class="item-name">${this._getItemName(item)}</div> <div class="item-name">${this._getItemName(item)}</div>
<div class="item-type">${item.type}</div> <div class="item-type">${item.type}</div>
<div class="item-price">${item.price} 💎</div> <div class="item-price">${item.price} 💎</div>
@ -535,7 +628,11 @@ export class MarketplaceScreen extends LitElement {
> >
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">Confirm Purchase</h3> <h3 class="modal-title">
${this.activeMerchant === "SELL"
? "Confirm Sell"
: "Confirm Purchase"}
</h3>
<button class="modal-close" @click=${this._closeModal}> <button class="modal-close" @click=${this._closeModal}>
</button> </button>
@ -547,10 +644,36 @@ export class MarketplaceScreen extends LitElement {
</div> </div>
<div class="item-type">${this.selectedItem.type}</div> <div class="item-type">${this.selectedItem.type}</div>
<div class="item-price"> <div class="item-price">
Price: ${this.selectedItem.price} 💎 ${this.activeMerchant === "SELL"
? "Sell Value:"
: "Price:"}
${this.selectedItem.price} 💎
</div>
<div
class="item-description"
style="margin-top: 10px; font-style: italic; color: #aaa;"
>
${this._getItemDescription(this.selectedItem)}
</div>
<div class="item-stats" style="margin-top: 10px;">
${Object.entries(
this._getItemStats(this.selectedItem)
).map(
([stat, value]) => html`
<div
style="display: flex; justify-content: space-between; font-size: 12px; color: #ccc;"
>
<span style="text-transform: capitalize;"
>${stat}</span
>
<span style="color: #00ffff;">${value}</span>
</div>
`
)}
</div> </div>
</div> </div>
${!this._canAfford(this.selectedItem) ${this.activeMerchant !== "SELL" &&
!this._canAfford(this.selectedItem)
? html`<div style="color: var(--color-accent-red);"> ? html`<div style="color: var(--color-accent-red);">
Insufficient funds! Insufficient funds!
</div>` </div>`
@ -559,10 +682,13 @@ export class MarketplaceScreen extends LitElement {
<div class="modal-actions"> <div class="modal-actions">
<button <button
class="btn btn-primary" class="btn btn-primary"
?disabled=${!this._canAfford(this.selectedItem)} ?disabled=${this.activeMerchant !== "SELL" &&
!this._canAfford(this.selectedItem)}
@click=${this._confirmBuy} @click=${this._confirmBuy}
> >
Buy for ${this.selectedItem.price} 💎 ${this.activeMerchant === "SELL"
? `Sell for ${this.selectedItem.price} 💎`
: `Buy for ${this.selectedItem.price} 💎`}
</button> </button>
<button class="btn" @click=${this._closeModal}>Cancel</button> <button class="btn" @click=${this._closeModal}>Cancel</button>
</div> </div>