diff --git a/public/assets/tiles0.png b/public/assets/tiles0.png new file mode 100644 index 0000000..cc42fdf Binary files /dev/null and b/public/assets/tiles0.png differ diff --git a/src/core/config/GameConfig.ts b/src/core/config/GameConfig.ts index 700f41f..23621e7 100644 --- a/src/core/config/GameConfig.ts +++ b/src/core/config/GameConfig.ts @@ -30,7 +30,7 @@ export const GAME_CONFIG = { }, rendering: { - tileSize: 24, + tileSize: 16, cameraZoom: 2, wallColor: 0x2b2b2b, floorColor: 0x161616, @@ -44,6 +44,15 @@ export const GAME_CONFIG = { visibleMaxAlpha: 1.0, visibleStrengthFactor: 0.65 }, + + terrain: { + empty: 1, + wall: 4, + water: 63, + emptyDeco: 24, + wallDeco: 12, + exit: 8 + }, ui: { minimapPanelWidth: 340, diff --git a/src/core/types.ts b/src/core/types.ts index c3bf6ac..6739e2f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -2,7 +2,7 @@ export type EntityId = number; export type Vec2 = { x: number; y: number }; -export type Tile = 0 | 1; // 0 = floor, 1 = wall +export type Tile = number; export type Action = | { type: "move"; dx: number; dy: number } diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 6e5b92b..d3ecf54 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -19,7 +19,7 @@ interface Room { export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } { const width = GAME_CONFIG.map.width; const height = GAME_CONFIG.map.height; - const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls + const tiles: Tile[] = new Array(width * height).fill(GAME_CONFIG.terrain.wall); // Start with all walls const random = seededRandom(level * 12345); @@ -52,6 +52,8 @@ export function generateWorld(level: number, runState: RunState): { world: World placeEnemies(level, rooms, actors, random); + decorate(width, height, tiles, random, exit); + return { world: { width, height, tiles, actors, exit }, playerId }; } @@ -99,7 +101,7 @@ function doesOverlap(newRoom: Room, rooms: Room[]): boolean { function carveRoom(room: Room, tiles: Tile[], world: any): void { for (let x = room.x; x < room.x + room.width; x++) { for (let y = room.y; y < room.y + room.height; y++) { - tiles[idx(world, x, y)] = 0; + tiles[idx(world, x, y)] = GAME_CONFIG.terrain.empty; } } } @@ -113,22 +115,95 @@ function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, rand if (random() < 0.5) { // Horizontal then vertical for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) { - tiles[idx(world, x, y1)] = 0; + tiles[idx(world, x, y1)] = GAME_CONFIG.terrain.empty; } for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { - tiles[idx(world, x2, y)] = 0; + tiles[idx(world, x2, y)] = GAME_CONFIG.terrain.empty; } } else { // Vertical then horizontal for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { - tiles[idx(world, x1, y)] = 0; + tiles[idx(world, x1, y)] = GAME_CONFIG.terrain.empty; } for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) { - tiles[idx(world, x, y2)] = 0; + tiles[idx(world, x, y2)] = GAME_CONFIG.terrain.empty; } } } +function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void { + const world = { width, height }; + + // Set exit tile + tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit; + + // Add water patches (similar to PD Sewers) + const waterMask = generatePatch(width, height, 0.45, 5, random); + for (let i = 0; i < tiles.length; i++) { + if (tiles[i] === GAME_CONFIG.terrain.empty && waterMask[i]) { + tiles[i] = GAME_CONFIG.terrain.water; + } + } + + // Wall decorations + for (let y = 0; y < height - 1; y++) { + for (let x = 0; x < width; x++) { + const i = idx(world as any, x, y); + const nextY = idx(world as any, x, y + 1); + + if (tiles[i] === GAME_CONFIG.terrain.wall && + tiles[nextY] === GAME_CONFIG.terrain.water && + random() < 0.25) { + tiles[i] = GAME_CONFIG.terrain.wallDeco; + } + } + } + + // Floor decorations (moss) + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const i = idx(world as any, x, y); + if (tiles[i] === GAME_CONFIG.terrain.empty) { + let wallCount = 0; + if (tiles[idx(world as any, x + 1, y)] === GAME_CONFIG.terrain.wall) wallCount++; + if (tiles[idx(world as any, x - 1, y)] === GAME_CONFIG.terrain.wall) wallCount++; + if (tiles[idx(world as any, x, y + 1)] === GAME_CONFIG.terrain.wall) wallCount++; + if (tiles[idx(world as any, x, y - 1)] === GAME_CONFIG.terrain.wall) wallCount++; + + if (random() * 16 < wallCount * wallCount) { + tiles[i] = GAME_CONFIG.terrain.emptyDeco; + } + } + } + } +} + +/** + * Simple cellular automata for generating patches of terrain + */ +function generatePatch(width: number, height: number, fillChance: number, iterations: number, random: () => number): boolean[] { + let map = new Array(width * height).fill(false).map(() => random() < fillChance); + + for (let step = 0; step < iterations; step++) { + const nextMap = new Array(width * height).fill(false); + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + let neighbors = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (map[(y + dy) * width + (x + dx)]) neighbors++; + } + } + if (neighbors > 4) nextMap[y * width + x] = true; + else if (neighbors < 4) nextMap[y * width + x] = false; + else nextMap[y * width + x] = map[y * width + x]; + } + } + map = nextMap; + } + return map; +} + function placeEnemies(level: number, rooms: Room[], actors: Map, random: () => number): void { let enemyId = 2; const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus); diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index 47fcd21..b1139c2 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -1,4 +1,5 @@ import type { World, EntityId } from "../../core/types"; +import { GAME_CONFIG } from "../../core/config/GameConfig"; export function inBounds(w: World, x: number, y: number): boolean { return x >= 0 && y >= 0 && x < w.width && y < w.height; @@ -9,7 +10,8 @@ export function idx(w: World, x: number, y: number): number { } export function isWall(w: World, x: number, y: number): boolean { - return w.tiles[idx(w, x, y)] === 1; + const tile = w.tiles[idx(w, x, y)]; + return tile === GAME_CONFIG.terrain.wall || tile === GAME_CONFIG.terrain.wallDeco; } export function isBlocked(w: World, x: number, y: number): boolean { diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index 7b1f91b..f71d369 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -7,7 +7,9 @@ import { GAME_CONFIG } from "../core/config/GameConfig"; export class DungeonRenderer { private scene: Phaser.Scene; - private gfx: Phaser.GameObjects.Graphics; + private map?: Phaser.Tilemaps.Tilemap; + private layer?: Phaser.Tilemaps.TilemapLayer; + private playerSprite?: Phaser.GameObjects.Sprite; private enemySprites: Map = new Map(); private corpseSprites: Phaser.GameObjects.Sprite[] = []; @@ -25,36 +27,28 @@ export class DungeonRenderer { private minimapGfx!: Phaser.GameObjects.Graphics; private minimapContainer!: Phaser.GameObjects.Container; private minimapBg!: Phaser.GameObjects.Rectangle; - private minimapVisible = false; // Off by default + private minimapVisible = false; constructor(scene: Phaser.Scene) { this.scene = scene; - this.gfx = this.scene.add.graphics(); - - // Initialize minimap this.initMinimap(); } private initMinimap() { this.minimapContainer = this.scene.add.container(0, 0); - this.minimapContainer.setScrollFactor(0); // Fixed to camera - this.minimapContainer.setDepth(1001); // Same as menu + this.minimapContainer.setScrollFactor(0); + this.minimapContainer.setDepth(1001); - // Background panel (like menu) this.minimapBg = this.scene.add .rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8) .setStrokeStyle(1, 0xffffff, 0.9) - .setInteractive(); // Capture clicks + .setInteractive(); this.minimapGfx = this.scene.add.graphics(); this.minimapContainer.add(this.minimapBg); this.minimapContainer.add(this.minimapGfx); - - // Position in center this.positionMinimap(); - - // Start hidden this.minimapContainer.setVisible(false); } @@ -64,22 +58,40 @@ export class DungeonRenderer { this.visible = new Uint8Array(this.world.width * this.world.height); this.visibleStrength = new Float32Array(this.world.width * this.world.height); + // Setup Tilemap + if (this.map) this.map.destroy(); + this.map = this.scene.make.tilemap({ + data: Array.from({ length: world.height }, (_, y) => + Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)]) + ), + tileWidth: 16, + tileHeight: 16 + }); + + const tileset = this.map.addTilesetImage("tiles0", "tiles0", 16, 16, 0, 0)!; + this.layer = this.map.createLayer(0, tileset, 0, 0)!; + this.layer.setDepth(0); + + // Initial tile states (hidden) + this.layer.forEachTile(tile => { + tile.setVisible(false); + }); + // Clear old corpses for (const sprite of this.corpseSprites) { sprite.destroy(); } this.corpseSprites = []; - // Setup player sprite + // Setup player sprite if (!this.playerSprite) { this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0); this.playerSprite.setDepth(100); - // Calculate display size to fit within tile while maintaining 12:15 aspect ratio - const scale = TILE_SIZE / 15; // Fit height to tile size + // Calculate scale to fit 15px high sprite into 16px tile + const scale = 1.0; this.playerSprite.setScale(scale); - // Simple animations from PD source this.scene.anims.create({ key: 'warrior-idle', frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }), @@ -104,7 +116,7 @@ export class DungeonRenderer { this.playerSprite.play('warrior-idle'); } - // Rat animations + // Enemy animations if (!this.scene.anims.exists('rat-idle')) { this.scene.anims.create({ key: 'rat-idle', @@ -126,7 +138,6 @@ export class DungeonRenderer { }); } - // Bat animations if (!this.scene.anims.exists('bat-idle')) { this.scene.anims.create({ key: 'bat-idle', @@ -153,13 +164,11 @@ export class DungeonRenderer { return !isWall(this.world, x, y); }); - // Position minimap this.positionMinimap(); } private positionMinimap() { const cam = this.scene.cameras.main; - // Center on screen like menu this.minimapContainer.setPosition(cam.width / 2, cam.height / 2); } @@ -187,7 +196,6 @@ export class DungeonRenderer { this.visible[i] = 1; this.seen[i] = 1; - // falloff: 1 at center, ~0.4 at radius edge const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1); const falloff = 1 - radiusT * 0.6; const strength = Phaser.Math.Clamp(v * falloff, 0, 1); @@ -205,67 +213,31 @@ export class DungeonRenderer { return this.seen; } - render(playerPath: Vec2[]) { - this.gfx.clear(); + render(_playerPath: Vec2[]) { + if (!this.world || !this.layer) return; - if (!this.world) return; + // Update Tiles + this.layer.forEachTile(tile => { + const i = idx(this.world, tile.x, tile.y); + const isSeen = this.seen[i] === 1; + const isVis = this.visible[i] === 1; - // Tiles w/ fog + falloff + silhouettes - for (let y = 0; y < this.world.height; y++) { - for (let x = 0; x < this.world.width; x++) { - const i = idx(this.world, x, y); - - const isSeen = this.seen[i] === 1; - const isVis = this.visible[i] === 1; - - if (!isSeen) { - this.gfx.fillStyle(0x000000, 1); - this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); - continue; - } - - const wall = isWall(this.world, x, y); - const base = wall ? GAME_CONFIG.rendering.wallColor : GAME_CONFIG.rendering.floorColor; - - let alpha: number; + if (!isSeen) { + tile.setVisible(false); + } else { + tile.setVisible(true); if (isVis) { - const s = this.visibleStrength[i]; - alpha = Phaser.Math.Clamp(GAME_CONFIG.rendering.visibleMinAlpha + s * GAME_CONFIG.rendering.visibleStrengthFactor, GAME_CONFIG.rendering.visibleMinAlpha, GAME_CONFIG.rendering.visibleMaxAlpha); + tile.alpha = 1.0; + tile.tint = 0xffffff; } else { - alpha = wall ? GAME_CONFIG.rendering.fogAlphaWall : GAME_CONFIG.rendering.fogAlphaFloor; + tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2; + tile.tint = 0x888888; } - - this.gfx.fillStyle(base, alpha); - this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } - } + }); - // Exit (stairs) if seen - { - const ex = this.world.exit.x; - const ey = this.world.exit.y; - const i = idx(this.world, ex, ey); - if (this.seen[i] === 1) { - const alpha = this.visible[i] === 1 ? 1.0 : GAME_CONFIG.rendering.visibleMinAlpha; - this.gfx.fillStyle(GAME_CONFIG.rendering.exitColor, alpha); - this.gfx.fillRect(ex * TILE_SIZE + 7, ey * TILE_SIZE + 7, TILE_SIZE - 14, TILE_SIZE - 14); - } - } - - // Path preview (seen only) - if (playerPath.length >= 2) { - this.gfx.fillStyle(GAME_CONFIG.rendering.pathPreviewColor, GAME_CONFIG.rendering.visibleMinAlpha); - for (const p of playerPath) { - // We can check isSeen via internal helper or just local array since we're inside - const i = idx(this.world, p.x, p.y); - if (this.seen[i] !== 1) continue; - this.gfx.fillRect(p.x * TILE_SIZE + 6, p.y * TILE_SIZE + 6, TILE_SIZE - 12, TILE_SIZE - 12); - } - } - - // Actors (enemies only if visible) + // Actors const activeEnemyIds = new Set(); - for (const a of this.world.actors.values()) { const i = idx(this.world, a.pos.x, a.pos.y); const isVis = this.visible[i] === 1; @@ -282,14 +254,11 @@ export class DungeonRenderer { activeEnemyIds.add(a.id); let sprite = this.enemySprites.get(a.id); - const textureKey = a.type === "bat" ? "bat" : "rat"; if (!sprite) { sprite = this.scene.add.sprite(0, 0, textureKey, 0); sprite.setDepth(99); - const scale = TILE_SIZE / 15; - sprite.setScale(scale); sprite.play(`${textureKey}-idle`); this.enemySprites.set(a.id, sprite); } @@ -298,11 +267,9 @@ export class DungeonRenderer { sprite.setVisible(true); } - // Hide/Cleanup inactive/non-visible enemy sprites for (const [id, sprite] of this.enemySprites.entries()) { if (!activeEnemyIds.has(id)) { sprite.setVisible(false); - // We could also destroy if they are dead, but hide is safer for now if (!this.world.actors.has(id)) { sprite.destroy(); this.enemySprites.delete(id); @@ -310,16 +277,13 @@ export class DungeonRenderer { } } - // Render minimap this.renderMinimap(); } private renderMinimap() { this.minimapGfx.clear(); - if (!this.world) return; - // Calculate scale to fit map within panel const padding = GAME_CONFIG.ui.minimapPadding; const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2; const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2; @@ -328,73 +292,44 @@ export class DungeonRenderer { const scaleY = availableHeight / this.world.height; const tileSize = Math.floor(Math.min(scaleX, scaleY)); - // Center the map within the panel const mapPixelWidth = this.world.width * tileSize; const mapPixelHeight = this.world.height * tileSize; const offsetX = -mapPixelWidth / 2; const offsetY = -mapPixelHeight / 2; - // Draw only seen tiles for (let y = 0; y < this.world.height; y++) { for (let x = 0; x < this.world.width; x++) { const i = idx(this.world, x, y); - const isSeen = this.seen[i] === 1; - - if (!isSeen) continue; + if (this.seen[i] !== 1) continue; const wall = isWall(this.world, x, y); const color = wall ? 0x666666 : 0x333333; this.minimapGfx.fillStyle(color, 1); - this.minimapGfx.fillRect( - offsetX + x * tileSize, - offsetY + y * tileSize, - tileSize, - tileSize - ); + this.minimapGfx.fillRect(offsetX + x * tileSize, offsetY + y * tileSize, tileSize, tileSize); } } - // Draw exit if seen const ex = this.world.exit.x; const ey = this.world.exit.y; - const exitIdx = idx(this.world, ex, ey); - if (this.seen[exitIdx] === 1) { + if (this.seen[idx(this.world, ex, ey)] === 1) { this.minimapGfx.fillStyle(0xffd166, 1); - this.minimapGfx.fillRect( - offsetX + ex * tileSize, - offsetY + ey * tileSize, - tileSize, - tileSize - ); + this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize); } - // Draw player const player = [...this.world.actors.values()].find(a => a.isPlayer); if (player) { this.minimapGfx.fillStyle(0x66ff66, 1); - this.minimapGfx.fillRect( - offsetX + player.pos.x * tileSize, - offsetY + player.pos.y * tileSize, - tileSize, - tileSize - ); + this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize); } - // Draw visible enemies for (const a of this.world.actors.values()) { if (a.isPlayer) continue; const i = idx(this.world, a.pos.x, a.pos.y); - const isVis = this.visible[i] === 1; - if (!isVis) continue; - - this.minimapGfx.fillStyle(0xff6666, 1); - this.minimapGfx.fillRect( - offsetX + a.pos.x * tileSize, - offsetY + a.pos.y * tileSize, - tileSize, - tileSize - ); + if (this.visible[i] === 1) { + this.minimapGfx.fillStyle(0xff6666, 1); + this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize); + } } } @@ -429,7 +364,6 @@ export class DungeonRenderer { 0 ); corpse.setDepth(50); - corpse.setScale(TILE_SIZE / 15); corpse.play(`${textureKey}-die`); this.corpseSprites.push(corpse); } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index b2dd863..fccbf81 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -42,6 +42,7 @@ export class GameScene extends Phaser.Scene { this.load.spritesheet("warrior", "warrior.png", { frameWidth: 12, frameHeight: 15 }); this.load.spritesheet("rat", "rat.png", { frameWidth: 16, frameHeight: 15 }); this.load.spritesheet("bat", "bat.png", { frameWidth: 15, frameHeight: 15 }); + this.load.spritesheet("tiles0", "assets/tiles0.png", { frameWidth: 16, frameHeight: 16 }); } create() {