459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
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<EntityId, Phaser.GameObjects.Sprite> = 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<EntityId>();
|
|
|
|
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()
|
|
});
|
|
}
|
|
}
|