diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6126376 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(while read:*)", + "Bash(do wc:*)", + "Bash(done)" + ] + } +} diff --git a/README.md b/README.md index 92e905d..547d195 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Descend through procedurally generated dungeons, fight monsters, collect loot, a - **45+ items** — Swords, axes, hammers, bows, armor, shields, helmets, potions, and scrolls - **Turn-based combat** — Every move counts; plan your approach carefully - **Equipment system** — Equip weapons and armor to boost your stats +- **Treasure** — Gold scattered through every level and dropped by slain monsters; collected automatically as you walk over it - **Field of view** — Explore using recursive shadowcasting; what lurks in the dark? - **Permadeath** — One life per run. High scores are saved locally - **Share your runs** — Post your results to Mastodon, Bluesky, or copy to clipboard @@ -24,10 +25,11 @@ Descend through procedurally generated dungeons, fight monsters, collect loot, a |-----|--------| | WASD / Arrow Keys | Move | | G | Pick up item | -| I | Open inventory | +| I | Open / close inventory | | > | Descend stairs | | . | Wait a turn | -| 1-9 | Use inventory item (when inventory is open) | +| 1-9 | Use inventory item (inventory open) | +| Shift+1-9 | Drop inventory item (inventory open) | | Esc | Close inventory | ### Tips @@ -35,9 +37,12 @@ Descend through procedurally generated dungeons, fight monsters, collect loot, a - Bump into a monster to attack it - Pick up weapons and armor, then open inventory and press their number to equip them - Potions are consumed immediately when used; weapons and armor are equipped -- Find the stairs down (>) to descend to the next depth -- Deeper floors have tougher monsters but better loot +- Walk over gold (`$`) to collect it automatically — it adds to your score +- Monsters have a chance to drop gold when slain; deeper monsters drop more +- Find the stairs down (`>`) to descend to the next depth +- Deeper floors have tougher monsters but better loot and more valuable treasure - Check your inventory to see how equipment affects your ATK and DEF stats +- Drop unwanted items with Shift+number to free up inventory space ## Development diff --git a/src/dungeon/populate.ts b/src/dungeon/populate.ts index af75fed..a7656eb 100644 --- a/src/dungeon/populate.ts +++ b/src/dungeon/populate.ts @@ -25,6 +25,7 @@ export function populateDungeon(state: GameState, rooms: Room[]): void { const depth = state.depth; const monsterCount = 3 + depth * 2; const itemCount = 2 + Math.floor(depth / 2); + const treasureCount = 2 + Math.floor(depth / 2); // Skip first room (player spawn) const spawnRooms = rooms.slice(1); @@ -62,4 +63,19 @@ export function populateDungeon(state: GameState, rooms: Room[]): void { }); state.entities.push(item); } + + // Spawn static treasure + for (let i = 0; i < treasureCount; i++) { + const room = spawnRooms[rand(0, spawnRooms.length - 1)]; + const pos = randomFloorInRoom(state, room); + if (!pos) continue; + + const value = rand(depth * 3, depth * 12); + const treasure = createEntity({ + position: { x: pos.x, y: pos.y }, + appearance: { name: 'Gold', char: '$', color: '#ffd700', sprite: 'treasure' }, + treasure: { value }, + }); + state.entities.push(treasure); + } } diff --git a/src/game.ts b/src/game.ts index bc2b1d4..34e16aa 100644 --- a/src/game.ts +++ b/src/game.ts @@ -5,7 +5,7 @@ import { populateDungeon } from './dungeon/populate'; import { moveEntity } from './systems/movement'; import { runAI } from './systems/ai'; import { computeFOV } from './systems/fov'; -import { pickupItem, useItem } from './systems/inventory'; +import { pickupItem, useItem, dropItem } from './systems/inventory'; import { loadHighScores, saveHighScore } from './systems/scoring'; import { render } from './render/renderer'; import { SpriteMap } from './render/sprite-loader'; @@ -46,6 +46,7 @@ export class Game { player, depth: 1, score: 0, + treasureCollected: 0, turn: 0, gameOver: false, messages: [], @@ -113,6 +114,7 @@ export class Game { player, depth, score: 0, + treasureCollected: 0, turn: 0, gameOver: false, messages: [`${template.name} enters the dungeon...`], @@ -142,6 +144,13 @@ export class Game { return; } + if (action.type === 'dropItem') { + dropItem(this.state, action.index); + this.state.uiMode = 'game'; + this.endTurn(); + return; + } + if (action.type === 'pickup') { pickupItem(this.state); this.endTurn(); diff --git a/src/render/hud.ts b/src/render/hud.ts index 44d61be..5e1dcc0 100644 --- a/src/render/hud.ts +++ b/src/render/hud.ts @@ -65,6 +65,8 @@ export function drawHud(ctx: CanvasRenderingContext2D, state: GameState): void { ctx.fillText(`DEF: ${stats.defense}${equip?.armor?.item?.defenseBonus ? '+' + equip.armor.item.defenseBonus : ''}`, statsX + 100, barY + 24); ctx.fillText(`Score: ${state.score}`, statsX + 200, barY + 8); ctx.fillText(`Turn: ${state.turn}`, statsX + 200, barY + 24); + ctx.fillStyle = '#ffd700'; + ctx.fillText(`Gold: ${state.treasureCollected}`, statsX + 300, barY + 8); // Message log const msgX = PADDING; @@ -210,7 +212,7 @@ export function drawInventoryScreen(ctx: CanvasRenderingContext2D, state: GameSt ctx.fillStyle = COLORS.textDim; ctx.font = '12px monospace'; ctx.textAlign = 'center'; - ctx.fillText('[1-9] Use item | [Esc/i] Close', CANVAS_W / 2, y); + ctx.fillText('[1-9] Use item | [Shift+1-9] Drop item | [Esc/i] Close', CANVAS_W / 2, y); } export function drawCharSelect( @@ -333,21 +335,23 @@ export function drawGameOver( ctx.fillText(`Score: ${state.score}`, CANVAS_W / 2, CANVAS_H / 2 - 10); ctx.fillText(`Depth Reached: ${state.depth}`, CANVAS_W / 2, CANVAS_H / 2 + 20); ctx.fillText(`Turns Survived: ${state.turn}`, CANVAS_W / 2, CANVAS_H / 2 + 50); + ctx.fillStyle = '#ffd700'; + ctx.fillText(`Gold Collected: ${state.treasureCollected}`, CANVAS_W / 2, CANVAS_H / 2 + 80); // High scores if (state.highScores.length > 0) { ctx.fillStyle = COLORS.stairs; ctx.font = 'bold 16px monospace'; - ctx.fillText('HIGH SCORES', CANVAS_W / 2, CANVAS_H / 2 + 100); + ctx.fillText('HIGH SCORES', CANVAS_W / 2, CANVAS_H / 2 + 120); ctx.font = '14px monospace'; ctx.fillStyle = COLORS.text; state.highScores.slice(0, 5).forEach((score, i) => { - ctx.fillText(`${i + 1}. ${score}`, CANVAS_W / 2, CANVAS_H / 2 + 125 + i * 20); + ctx.fillText(`${i + 1}. ${score}`, CANVAS_W / 2, CANVAS_H / 2 + 145 + i * 20); }); } // Share options - const shareY = CANVAS_H / 2 + 235; + const shareY = CANVAS_H / 2 + 255; ctx.fillStyle = COLORS.stairs; ctx.font = 'bold 14px monospace'; ctx.fillText('SHARE YOUR RESULT', CANVAS_W / 2, shareY); diff --git a/src/systems/combat.ts b/src/systems/combat.ts index 744c0f2..2d4d6ce 100644 --- a/src/systems/combat.ts +++ b/src/systems/combat.ts @@ -1,4 +1,5 @@ import { Entity, GameState } from '../types'; +import { createEntity } from '../ecs/entity'; function getAttackPower(entity: Entity): number { let atk = entity.stats?.attack ?? 0; @@ -55,6 +56,17 @@ function killEntity(state: GameState, victim: Entity, killer: Entity): void { checkLevelUp(state, killer); } + // Chance to drop treasure + if (victim.position && victim.xpValue && Math.random() < 0.4) { + const value = Math.max(1, Math.ceil(victim.xpValue / 3) + Math.floor(Math.random() * victim.xpValue / 3)); + const drop = createEntity({ + position: { x: victim.position.x, y: victim.position.y }, + appearance: { name: 'Gold', char: '$', color: '#ffd700', sprite: 'treasure' }, + treasure: { value }, + }); + state.entities.push(drop); + } + // Remove from entities const idx = state.entities.indexOf(victim); if (idx >= 0) { diff --git a/src/systems/input.ts b/src/systems/input.ts index c6ec5b7..8d0c229 100644 --- a/src/systems/input.ts +++ b/src/systems/input.ts @@ -23,6 +23,11 @@ function keyToAction(key: string, mode: UIMode): Action | null { if (num >= 1 && num <= 9) { return { type: 'useItem', index: num - 1 }; } + const dropKeys = '!@#$%^&*('; + const dropIndex = dropKeys.indexOf(key); + if (dropIndex >= 0) { + return { type: 'dropItem', index: dropIndex }; + } return null; } diff --git a/src/systems/inventory.ts b/src/systems/inventory.ts index b561710..8cf861d 100644 --- a/src/systems/inventory.ts +++ b/src/systems/inventory.ts @@ -118,6 +118,19 @@ function equipWeapon(state: GameState, item: Entity, invIndex: number): void { state.messages.push(`Equipped ${item.appearance?.name ?? 'weapon'}.`); } +export function dropItem(state: GameState, index: number): void { + const inv = state.player.inventory; + if (!inv || index < 0 || index >= inv.items.length) return; + + const item = inv.items[index]; + const pos = state.player.position!; + + inv.items.splice(index, 1); + item.position = { x: pos.x, y: pos.y }; + state.entities.push(item); + state.messages.push(`Dropped ${item.appearance?.name ?? 'item'}.`); +} + function equipArmor(state: GameState, item: Entity, invIndex: number): void { const equip = state.player.equipment!; const inv = state.player.inventory!; diff --git a/src/systems/movement.ts b/src/systems/movement.ts index 41c4be8..64919d8 100644 --- a/src/systems/movement.ts +++ b/src/systems/movement.ts @@ -38,5 +38,21 @@ export function moveEntity( // Move entity.position.x = newX; entity.position.y = newY; + + // Auto-collect treasure on the new tile + if (entity.player) { + const treasureIdx = state.entities.findIndex( + (e) => e.treasure && e.position && e.position.x === newX && e.position.y === newY + ); + if (treasureIdx >= 0) { + const t = state.entities[treasureIdx]; + const value = t.treasure!.value; + state.treasureCollected += value; + state.score += value; + state.entities.splice(treasureIdx, 1); + state.messages.push(`You found ${value} gold!`); + } + } + return true; } diff --git a/src/types.ts b/src/types.ts index f9e40fc..f6ccc12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,10 @@ export interface ItemComponent { defenseBonus?: number; } +export interface TreasureComponent { + value: number; +} + export type UseEffect = | { type: 'heal'; amount: number } | { type: 'damage'; amount: number; range: number }; @@ -57,6 +61,7 @@ export interface Entity { inventory?: InventoryComponent; equipment?: EquipmentComponent; item?: ItemComponent; + treasure?: TreasureComponent; player?: true; blocksMovement?: true; xpValue?: number; @@ -81,6 +86,7 @@ export type Action = | { type: 'wait' } | { type: 'pickup' } | { type: 'useItem'; index: number } + | { type: 'dropItem'; index: number } | { type: 'descend' } | { type: 'toggleInventory' }; @@ -92,6 +98,7 @@ export interface GameState { player: Entity; depth: number; score: number; + treasureCollected: number; turn: number; gameOver: boolean; messages: string[];