Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(while read:*)",
"Bash(do wc:*)",
"Bash(done)"
]
}
}
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,20 +25,24 @@ 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

- 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

Expand Down
16 changes: 16 additions & 0 deletions src/dungeon/populate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
11 changes: 10 additions & 1 deletion src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,6 +46,7 @@ export class Game {
player,
depth: 1,
score: 0,
treasureCollected: 0,
turn: 0,
gameOver: false,
messages: [],
Expand Down Expand Up @@ -113,6 +114,7 @@ export class Game {
player,
depth,
score: 0,
treasureCollected: 0,
turn: 0,
gameOver: false,
messages: [`${template.name} enters the dungeon...`],
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 8 additions & 4 deletions src/render/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/systems/combat.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/systems/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
13 changes: 13 additions & 0 deletions src/systems/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down
16 changes: 16 additions & 0 deletions src/systems/movement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -57,6 +61,7 @@ export interface Entity {
inventory?: InventoryComponent;
equipment?: EquipmentComponent;
item?: ItemComponent;
treasure?: TreasureComponent;
player?: true;
blocksMovement?: true;
xpValue?: number;
Expand All @@ -81,6 +86,7 @@ export type Action =
| { type: 'wait' }
| { type: 'pickup' }
| { type: 'useItem'; index: number }
| { type: 'dropItem'; index: number }
| { type: 'descend' }
| { type: 'toggleInventory' };

Expand All @@ -92,6 +98,7 @@ export interface GameState {
player: Entity;
depth: number;
score: number;
treasureCollected: number;
turn: number;
gameOver: boolean;
messages: string[];
Expand Down
Loading