import Phaser from "phaser"; import { FOV } from "rot-js"; import { type World, type EntityId, type Vec2 } from "../core/types"; import { TILE_SIZE } from "../core/constants"; import { idx, inBounds, isWall } from "../engine/world/world-logic"; import { GAME_CONFIG } from "../core/config/GameConfig"; export class DungeonRenderer { private scene: Phaser.Scene; private gfx: Phaser.GameObjects.Graphics; private playerSprite?: Phaser.GameObjects.Sprite; private enemySprites: Map = new Map(); private corpseSprites: Phaser.GameObjects.Sprite[] = []; // FOV private fov!: any; private seen!: Uint8Array; private visible!: Uint8Array; private visibleStrength!: Float32Array; // State refs private world!: World; // Minimap private minimapGfx!: Phaser.GameObjects.Graphics; private minimapContainer!: Phaser.GameObjects.Container; private minimapBg!: Phaser.GameObjects.Rectangle; private minimapVisible = false; // Off by default 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 // 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 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); } initializeLevel(world: World) { this.world = world; this.seen = new Uint8Array(this.world.width * this.world.height); this.visible = new Uint8Array(this.world.width * this.world.height); this.visibleStrength = new Float32Array(this.world.width * this.world.height); // Clear old corpses for (const sprite of this.corpseSprites) { sprite.destroy(); } this.corpseSprites = []; // 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 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] }), frameRate: 2, repeat: -1 }); this.scene.anims.create({ key: 'warrior-run', frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [2, 3, 4, 5, 6, 7] }), frameRate: 15, repeat: -1 }); this.scene.anims.create({ key: 'warrior-die', frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [8, 9, 10, 11, 12] }), frameRate: 10, repeat: 0 }); this.playerSprite.play('warrior-idle'); } // Rat animations if (!this.scene.anims.exists('rat-idle')) { this.scene.anims.create({ key: 'rat-idle', frames: this.scene.anims.generateFrameNumbers('rat', { frames: [0, 0, 0, 1] }), frameRate: 4, repeat: -1 }); this.scene.anims.create({ key: 'rat-run', frames: this.scene.anims.generateFrameNumbers('rat', { frames: [6, 7, 8, 9, 10] }), frameRate: 10, repeat: -1 }); this.scene.anims.create({ key: 'rat-die', frames: this.scene.anims.generateFrameNumbers('rat', { frames: [11, 12, 13, 14] }), frameRate: 10, repeat: 0 }); } // Bat animations if (!this.scene.anims.exists('bat-idle')) { this.scene.anims.create({ key: 'bat-idle', frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }), frameRate: 8, repeat: -1 }); this.scene.anims.create({ key: 'bat-run', frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }), frameRate: 12, repeat: -1 }); this.scene.anims.create({ key: 'bat-die', frames: this.scene.anims.generateFrameNumbers('bat', { frames: [4, 5, 6] }), frameRate: 10, repeat: 0 }); } this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => { if (!inBounds(this.world, x, y)) return false; 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); } toggleMinimap() { this.minimapVisible = !this.minimapVisible; this.minimapContainer.setVisible(this.minimapVisible); } isMinimapVisible(): boolean { return this.minimapVisible; } computeFov(playerId: EntityId) { this.visible.fill(0); this.visibleStrength.fill(0); const player = this.world.actors.get(playerId)!; const ox = player.pos.x; const oy = player.pos.y; this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => { if (!inBounds(this.world, x, y)) return; const i = idx(this.world, x, y); 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); if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength; }); } isSeen(x: number, y: number): boolean { if (!this.world || !inBounds(this.world, x, y)) return false; return this.seen[idx(this.world, x, y)] === 1; } get seenArray() { return this.seen; } render(playerPath: Vec2[]) { this.gfx.clear(); if (!this.world) return; // 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 (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); } else { alpha = wall ? GAME_CONFIG.rendering.fogAlphaWall : GAME_CONFIG.rendering.fogAlphaFloor; } 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) 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; if (a.isPlayer) { if (this.playerSprite) { this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); this.playerSprite.setVisible(true); } continue; } if (!isVis) continue; 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); } sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); 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); } } } // 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; const scaleX = availableWidth / this.world.width; 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; 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 ); } } // 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) { this.minimapGfx.fillStyle(0xffd166, 1); 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 ); } // 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 ); } } showDamage(x: number, y: number, amount: number) { const screenX = x * TILE_SIZE + TILE_SIZE / 2; const screenY = y * TILE_SIZE; const text = this.scene.add.text(screenX, screenY, amount.toString(), { fontSize: "16px", color: "#ff3333", stroke: "#000", strokeThickness: 2, fontStyle: "bold" }).setOrigin(0.5, 1).setDepth(200); this.scene.tweens.add({ targets: text, y: screenY - 24, alpha: 0, duration: 800, ease: "Power1", onComplete: () => text.destroy() }); } spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") { const textureKey = type === "player" ? "warrior" : type; const corpse = this.scene.add.sprite( x * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2, textureKey, 0 ); corpse.setDepth(50); corpse.setScale(TILE_SIZE / 15); corpse.play(`${textureKey}-die`); this.corpseSprites.push(corpse); } showWait(x: number, y: number) { const screenX = x * TILE_SIZE + TILE_SIZE / 2; const screenY = y * TILE_SIZE; const text = this.scene.add.text(screenX, screenY, "zZz", { fontSize: "14px", color: "#aaaaff", stroke: "#000", strokeThickness: 2, fontStyle: "bold" }).setOrigin(0.5, 1).setDepth(200); this.scene.tweens.add({ targets: text, y: screenY - 20, alpha: 0, duration: 600, ease: "Power1", onComplete: () => text.destroy() }); } }