Add more stats, crit/block/accuracy/dodge/lifesteal
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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 { idx, isWall } from "../engine/world/world-logic";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
import { FovManager } from "./FovManager";
|
||||
import { MinimapRenderer } from "./MinimapRenderer";
|
||||
import { FxRenderer } from "./FxRenderer";
|
||||
|
||||
export class DungeonRenderer {
|
||||
private scene: Phaser.Scene;
|
||||
@@ -13,53 +15,23 @@ export class DungeonRenderer {
|
||||
private playerSprite?: Phaser.GameObjects.Sprite;
|
||||
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
|
||||
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
|
||||
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
|
||||
|
||||
private fovManager: FovManager;
|
||||
private minimapRenderer: MinimapRenderer;
|
||||
private fxRenderer: FxRenderer;
|
||||
|
||||
// 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;
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene;
|
||||
this.initMinimap();
|
||||
}
|
||||
|
||||
private initMinimap() {
|
||||
this.minimapContainer = this.scene.add.container(0, 0);
|
||||
this.minimapContainer.setScrollFactor(0);
|
||||
this.minimapContainer.setDepth(1001);
|
||||
|
||||
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();
|
||||
|
||||
this.minimapGfx = this.scene.add.graphics();
|
||||
|
||||
this.minimapContainer.add(this.minimapBg);
|
||||
this.minimapContainer.add(this.minimapGfx);
|
||||
this.positionMinimap();
|
||||
this.minimapContainer.setVisible(false);
|
||||
this.fovManager = new FovManager();
|
||||
this.minimapRenderer = new MinimapRenderer(scene);
|
||||
this.fxRenderer = new FxRenderer(scene);
|
||||
}
|
||||
|
||||
initializeFloor(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);
|
||||
this.fovManager.initialize(world);
|
||||
|
||||
// Setup Tilemap
|
||||
if (this.map) this.map.destroy();
|
||||
@@ -80,21 +52,17 @@ export class DungeonRenderer {
|
||||
tile.setVisible(false);
|
||||
});
|
||||
|
||||
// Clear old corpses
|
||||
for (const sprite of this.corpseSprites) {
|
||||
sprite.destroy();
|
||||
}
|
||||
this.corpseSprites = [];
|
||||
this.fxRenderer.clearCorpses();
|
||||
this.setupAnimations();
|
||||
this.minimapRenderer.positionMinimap();
|
||||
}
|
||||
|
||||
// Setup player sprite
|
||||
private setupAnimations() {
|
||||
// Player
|
||||
if (!this.playerSprite) {
|
||||
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
|
||||
this.playerSprite.setDepth(100);
|
||||
|
||||
// Calculate scale to fit 15px high sprite into 16px tile
|
||||
const scale = 1.0;
|
||||
this.playerSprite.setScale(scale);
|
||||
|
||||
this.scene.anims.create({
|
||||
key: 'warrior-idle',
|
||||
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }),
|
||||
@@ -161,69 +129,39 @@ export class DungeonRenderer {
|
||||
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);
|
||||
});
|
||||
|
||||
this.positionMinimap();
|
||||
}
|
||||
|
||||
private positionMinimap() {
|
||||
const cam = this.scene.cameras.main;
|
||||
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
|
||||
}
|
||||
|
||||
toggleMinimap() {
|
||||
this.minimapVisible = !this.minimapVisible;
|
||||
this.minimapContainer.setVisible(this.minimapVisible);
|
||||
this.minimapRenderer.toggle();
|
||||
}
|
||||
|
||||
isMinimapVisible(): boolean {
|
||||
return this.minimapVisible;
|
||||
return this.minimapRenderer.isVisible();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
});
|
||||
this.fovManager.compute(this.world, playerId);
|
||||
}
|
||||
|
||||
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;
|
||||
return this.fovManager.isSeen(x, y);
|
||||
}
|
||||
|
||||
get seenArray() {
|
||||
return this.seen;
|
||||
return this.fovManager.seenArray;
|
||||
}
|
||||
|
||||
render(_playerPath: Vec2[]) {
|
||||
if (!this.world || !this.layer) return;
|
||||
|
||||
const seen = this.fovManager.seenArray;
|
||||
const visible = this.fovManager.visibleArray;
|
||||
|
||||
// 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;
|
||||
const isSeen = seen[i] === 1;
|
||||
const isVis = visible[i] === 1;
|
||||
|
||||
if (!isSeen) {
|
||||
tile.setVisible(false);
|
||||
@@ -239,40 +177,57 @@ export class DungeonRenderer {
|
||||
}
|
||||
});
|
||||
|
||||
// Actors
|
||||
// Actors (Combatants)
|
||||
const activeEnemyIds = new Set<EntityId>();
|
||||
const activeOrbIds = 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);
|
||||
const isVis = visible[i] === 1;
|
||||
|
||||
if (a.category === "combatant") {
|
||||
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;
|
||||
|
||||
if (!sprite) {
|
||||
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
||||
sprite.setDepth(99);
|
||||
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);
|
||||
} else if (a.category === "collectible") {
|
||||
if (a.type === "exp_orb") {
|
||||
if (!isVis) continue;
|
||||
|
||||
activeOrbIds.add(a.id);
|
||||
let orb = this.orbSprites.get(a.id);
|
||||
if (!orb) {
|
||||
orb = this.scene.add.circle(0, 0, 4, GAME_CONFIG.rendering.expOrbColor);
|
||||
orb.setStrokeStyle(1, 0xffffff, 0.5);
|
||||
orb.setDepth(45);
|
||||
this.orbSprites.set(a.id, orb);
|
||||
}
|
||||
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||
orb.setVisible(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isVis) continue;
|
||||
|
||||
const enemyType = a.type as keyof typeof GAME_CONFIG.enemies;
|
||||
if (!GAME_CONFIG.enemies[enemyType]) continue;
|
||||
|
||||
activeEnemyIds.add(a.id);
|
||||
let sprite = this.enemySprites.get(a.id);
|
||||
const textureKey = a.type || "rat";
|
||||
|
||||
if (!sprite) {
|
||||
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
||||
sprite.setDepth(99);
|
||||
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);
|
||||
}
|
||||
|
||||
// Cleanup sprites for removed actors
|
||||
for (const [id, sprite] of this.enemySprites.entries()) {
|
||||
if (!activeEnemyIds.has(id)) {
|
||||
sprite.setVisible(false);
|
||||
@@ -283,30 +238,6 @@ export class DungeonRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// Orbs
|
||||
const activeOrbIds = new Set<EntityId>();
|
||||
for (const a of this.world.actors.values()) {
|
||||
if (a.type !== "exp_orb") continue;
|
||||
|
||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||
// PD usually shows items only when visible or seen. Let's do visible.
|
||||
const isVis = this.visible[i] === 1;
|
||||
|
||||
|
||||
if (!isVis) continue;
|
||||
|
||||
activeOrbIds.add(a.id);
|
||||
let orb = this.orbSprites.get(a.id);
|
||||
if (!orb) {
|
||||
orb = this.scene.add.circle(0, 0, 4, GAME_CONFIG.rendering.expOrbColor);
|
||||
orb.setStrokeStyle(1, 0xffffff, 0.5);
|
||||
orb.setDepth(45);
|
||||
this.orbSprites.set(a.id, orb);
|
||||
}
|
||||
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||
orb.setVisible(true);
|
||||
}
|
||||
|
||||
for (const [id, orb] of this.orbSprites.entries()) {
|
||||
if (!activeOrbIds.has(id)) {
|
||||
orb.setVisible(false);
|
||||
@@ -317,169 +248,39 @@ export class DungeonRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.renderMinimap();
|
||||
this.minimapRenderer.render(this.world, seen, visible);
|
||||
}
|
||||
|
||||
private renderMinimap() {
|
||||
this.minimapGfx.clear();
|
||||
if (!this.world) return;
|
||||
|
||||
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));
|
||||
|
||||
const mapPixelWidth = this.world.width * tileSize;
|
||||
const mapPixelHeight = this.world.height * tileSize;
|
||||
const offsetX = -mapPixelWidth / 2;
|
||||
const offsetY = -mapPixelHeight / 2;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const ex = this.world.exit.x;
|
||||
const ey = this.world.exit.y;
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
for (const a of this.world.actors.values()) {
|
||||
if (a.isPlayer) continue;
|
||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// FX Delegations
|
||||
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
|
||||
this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock);
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
showDodge(x: number, y: number) {
|
||||
this.fxRenderer.showDodge(x, y);
|
||||
}
|
||||
|
||||
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat" | "exp_orb") {
|
||||
if (type === "exp_orb") return;
|
||||
const textureKey = type === "player" ? "warrior" : type;
|
||||
showHeal(x: number, y: number, amount: number) {
|
||||
this.fxRenderer.showHeal(x, y, amount);
|
||||
}
|
||||
|
||||
const corpse = this.scene.add.sprite(
|
||||
x * TILE_SIZE + TILE_SIZE / 2,
|
||||
y * TILE_SIZE + TILE_SIZE / 2,
|
||||
textureKey,
|
||||
0
|
||||
);
|
||||
corpse.setDepth(50);
|
||||
corpse.play(`${textureKey}-die`);
|
||||
this.corpseSprites.push(corpse);
|
||||
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") {
|
||||
this.fxRenderer.spawnCorpse(x, y, type);
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
this.fxRenderer.showWait(x, y);
|
||||
}
|
||||
|
||||
spawnOrb(_orbId: EntityId, _x: number, _y: number) {
|
||||
// Just to trigger a render update if needed, but render() handles it
|
||||
// Handled in render()
|
||||
}
|
||||
|
||||
collectOrb(_actorId: EntityId, amount: number, 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, `+${amount} EXP`, {
|
||||
fontSize: "14px",
|
||||
color: "#" + GAME_CONFIG.rendering.expTextColor.toString(16),
|
||||
stroke: "#000",
|
||||
strokeThickness: 2,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5, 1).setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
y: screenY - 32,
|
||||
alpha: 0,
|
||||
duration: 1000,
|
||||
ease: "Power1",
|
||||
onComplete: () => text.destroy()
|
||||
});
|
||||
collectOrb(actorId: EntityId, amount: number, x: number, y: number) {
|
||||
this.fxRenderer.collectOrb(actorId, amount, x, y);
|
||||
}
|
||||
|
||||
showLevelUp(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 - 16, "+1 LVL", {
|
||||
fontSize: "20px",
|
||||
color: "#" + GAME_CONFIG.rendering.levelUpColor.toString(16),
|
||||
stroke: "#000",
|
||||
strokeThickness: 3,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5, 1).setDepth(210);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
y: screenY - 60,
|
||||
alpha: 0,
|
||||
duration: 1500,
|
||||
ease: "Cubic.out",
|
||||
onComplete: () => text.destroy()
|
||||
});
|
||||
this.fxRenderer.showLevelUp(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
src/rendering/FovManager.ts
Normal file
68
src/rendering/FovManager.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { FOV } from "rot-js";
|
||||
import { type World, type EntityId } from "../core/types";
|
||||
import { idx, inBounds, isWall } from "../engine/world/world-logic";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
import Phaser from "phaser";
|
||||
|
||||
export class FovManager {
|
||||
private fov!: any;
|
||||
private seen!: Uint8Array;
|
||||
private visible!: Uint8Array;
|
||||
private visibleStrength!: Float32Array;
|
||||
private worldWidth: number = 0;
|
||||
private worldHeight: number = 0;
|
||||
|
||||
initialize(world: World) {
|
||||
this.worldWidth = world.width;
|
||||
this.worldHeight = world.height;
|
||||
this.seen = new Uint8Array(world.width * world.height);
|
||||
this.visible = new Uint8Array(world.width * world.height);
|
||||
this.visibleStrength = new Float32Array(world.width * world.height);
|
||||
|
||||
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||
if (!inBounds(world, x, y)) return false;
|
||||
return !isWall(world, x, y);
|
||||
});
|
||||
}
|
||||
|
||||
compute(world: World, playerId: EntityId) {
|
||||
this.visible.fill(0);
|
||||
this.visibleStrength.fill(0);
|
||||
|
||||
const player = 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(world, x, y)) return;
|
||||
|
||||
const i = idx(world, x, y);
|
||||
this.visible[i] = 1;
|
||||
this.seen[i] = 1;
|
||||
|
||||
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 (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
|
||||
return this.seen[y * this.worldWidth + x] === 1;
|
||||
}
|
||||
|
||||
isVisible(x: number, y: number): boolean {
|
||||
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
|
||||
return this.visible[y * this.worldWidth + x] === 1;
|
||||
}
|
||||
|
||||
get seenArray() {
|
||||
return this.seen;
|
||||
}
|
||||
|
||||
get visibleArray() {
|
||||
return this.visible;
|
||||
}
|
||||
}
|
||||
191
src/rendering/FxRenderer.ts
Normal file
191
src/rendering/FxRenderer.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import Phaser from "phaser";
|
||||
import { type EntityId } from "../core/types";
|
||||
import { TILE_SIZE } from "../core/constants";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
|
||||
export class FxRenderer {
|
||||
private scene: Phaser.Scene;
|
||||
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
clearCorpses() {
|
||||
for (const sprite of this.corpseSprites) {
|
||||
sprite.destroy();
|
||||
}
|
||||
this.corpseSprites = [];
|
||||
}
|
||||
|
||||
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
|
||||
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
|
||||
const screenY = y * TILE_SIZE;
|
||||
|
||||
let textStr = amount.toString();
|
||||
let color = "#ff3333";
|
||||
let fontSize = "16px";
|
||||
|
||||
if (isCrit) {
|
||||
textStr += "!";
|
||||
color = "#ffff00";
|
||||
fontSize = "22px";
|
||||
}
|
||||
|
||||
const text = this.scene.add.text(screenX, screenY, textStr, {
|
||||
fontSize,
|
||||
color,
|
||||
stroke: "#000",
|
||||
strokeThickness: 2,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5, 1).setDepth(200);
|
||||
|
||||
if (isBlock) {
|
||||
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
|
||||
fontSize: "10px",
|
||||
color: "#888888",
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0, 1).setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: blockText,
|
||||
y: screenY - 34,
|
||||
alpha: 0,
|
||||
duration: 800,
|
||||
onComplete: () => blockText.destroy()
|
||||
});
|
||||
}
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
y: screenY - 24,
|
||||
alpha: 0,
|
||||
duration: isCrit ? 1200 : 800,
|
||||
ease: isCrit ? "Bounce.out" : "Power1",
|
||||
onComplete: () => text.destroy()
|
||||
});
|
||||
}
|
||||
|
||||
showDodge(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, "Dodge", {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
stroke: "#000",
|
||||
strokeThickness: 2,
|
||||
fontStyle: "italic"
|
||||
}).setOrigin(0.5, 1).setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
x: screenX + (Math.random() > 0.5 ? 20 : -20),
|
||||
y: screenY - 20,
|
||||
alpha: 0,
|
||||
duration: 600,
|
||||
onComplete: () => text.destroy()
|
||||
});
|
||||
}
|
||||
|
||||
showHeal(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}`, {
|
||||
fontSize: "16px",
|
||||
color: "#33ff33",
|
||||
stroke: "#000",
|
||||
strokeThickness: 2,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5, 1).setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
y: screenY - 30,
|
||||
alpha: 0,
|
||||
duration: 1000,
|
||||
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.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()
|
||||
});
|
||||
}
|
||||
|
||||
collectOrb(_actorId: EntityId, amount: number, 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, `+${amount} EXP`, {
|
||||
fontSize: "14px",
|
||||
color: "#" + GAME_CONFIG.rendering.expTextColor.toString(16),
|
||||
stroke: "#000",
|
||||
strokeThickness: 2,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5, 1).setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
y: screenY - 32,
|
||||
alpha: 0,
|
||||
duration: 1000,
|
||||
ease: "Power1",
|
||||
onComplete: () => text.destroy()
|
||||
});
|
||||
}
|
||||
|
||||
showLevelUp(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 - 16, "+1 LVL", {
|
||||
fontSize: "20px",
|
||||
color: "#" + GAME_CONFIG.rendering.levelUpColor.toString(16),
|
||||
stroke: "#000",
|
||||
strokeThickness: 3,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5, 1).setDepth(210);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: text,
|
||||
y: screenY - 60,
|
||||
alpha: 0,
|
||||
duration: 1500,
|
||||
ease: "Cubic.out",
|
||||
onComplete: () => text.destroy()
|
||||
});
|
||||
}
|
||||
}
|
||||
104
src/rendering/MinimapRenderer.ts
Normal file
104
src/rendering/MinimapRenderer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import Phaser from "phaser";
|
||||
import { type World, type CombatantActor } from "../core/types";
|
||||
import { idx, isWall } from "../engine/world/world-logic";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
|
||||
export class MinimapRenderer {
|
||||
private scene: Phaser.Scene;
|
||||
private minimapGfx!: Phaser.GameObjects.Graphics;
|
||||
private minimapContainer!: Phaser.GameObjects.Container;
|
||||
private minimapBg!: Phaser.GameObjects.Rectangle;
|
||||
private minimapVisible = false;
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene;
|
||||
this.initMinimap();
|
||||
}
|
||||
|
||||
private initMinimap() {
|
||||
this.minimapContainer = this.scene.add.container(0, 0);
|
||||
this.minimapContainer.setScrollFactor(0);
|
||||
this.minimapContainer.setDepth(1001);
|
||||
|
||||
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();
|
||||
|
||||
this.minimapGfx = this.scene.add.graphics();
|
||||
|
||||
this.minimapContainer.add(this.minimapBg);
|
||||
this.minimapContainer.add(this.minimapGfx);
|
||||
this.positionMinimap();
|
||||
this.minimapContainer.setVisible(false);
|
||||
}
|
||||
|
||||
positionMinimap() {
|
||||
const cam = this.scene.cameras.main;
|
||||
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.minimapVisible = !this.minimapVisible;
|
||||
this.minimapContainer.setVisible(this.minimapVisible);
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.minimapVisible;
|
||||
}
|
||||
|
||||
render(world: World, seen: Uint8Array, visible: Uint8Array) {
|
||||
this.minimapGfx.clear();
|
||||
if (!world) return;
|
||||
|
||||
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 / world.width;
|
||||
const scaleY = availableHeight / world.height;
|
||||
const tileSize = Math.floor(Math.min(scaleX, scaleY));
|
||||
|
||||
const mapPixelWidth = world.width * tileSize;
|
||||
const mapPixelHeight = world.height * tileSize;
|
||||
const offsetX = -mapPixelWidth / 2;
|
||||
const offsetY = -mapPixelHeight / 2;
|
||||
|
||||
for (let y = 0; y < world.height; y++) {
|
||||
for (let x = 0; x < world.width; x++) {
|
||||
const i = idx(world, x, y);
|
||||
if (seen[i] !== 1) continue;
|
||||
|
||||
const wall = isWall(world, x, y);
|
||||
const color = wall ? 0x666666 : 0x333333;
|
||||
|
||||
this.minimapGfx.fillStyle(color, 1);
|
||||
this.minimapGfx.fillRect(offsetX + x * tileSize, offsetY + y * tileSize, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
const ex = world.exit.x;
|
||||
const ey = world.exit.y;
|
||||
if (seen[idx(world, ex, ey)] === 1) {
|
||||
this.minimapGfx.fillStyle(0xffd166, 1);
|
||||
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
|
||||
}
|
||||
|
||||
const player = [...world.actors.values()].find(a => a.category === "combatant" && a.isPlayer) as CombatantActor;
|
||||
if (player) {
|
||||
this.minimapGfx.fillStyle(0x66ff66, 1);
|
||||
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
|
||||
}
|
||||
|
||||
for (const a of world.actors.values()) {
|
||||
if (a.category === "combatant") {
|
||||
if (a.isPlayer) continue;
|
||||
const i = idx(world, a.pos.x, a.pos.y);
|
||||
if (visible[i] === 1) {
|
||||
this.minimapGfx.fillStyle(0xff6666, 1);
|
||||
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,17 +147,16 @@ describe('DungeonRenderer', () => {
|
||||
renderer.initializeFloor(mockWorld);
|
||||
|
||||
// Add an exp_orb to the world
|
||||
mockWorld.actors.set(99, {
|
||||
id: 99,
|
||||
isPlayer: false,
|
||||
type: 'exp_orb',
|
||||
pos: { x: 5, y: 5 },
|
||||
speed: 0,
|
||||
energy: 0
|
||||
mockWorld.actors.set(2, {
|
||||
id: 2,
|
||||
category: "collectible",
|
||||
type: "exp_orb",
|
||||
pos: { x: 2, y: 1 },
|
||||
expAmount: 10
|
||||
});
|
||||
|
||||
// Make the tile visible for it to render
|
||||
(renderer as any).visible[5 * mockWorld.width + 5] = 1;
|
||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
|
||||
|
||||
// Reset mocks
|
||||
mockScene.add.sprite.mockClear();
|
||||
@@ -186,17 +185,18 @@ describe('DungeonRenderer', () => {
|
||||
renderer.initializeFloor(mockWorld);
|
||||
|
||||
// Add a rat (defined in config)
|
||||
mockWorld.actors.set(100, {
|
||||
id: 100,
|
||||
mockWorld.actors.set(3, {
|
||||
id: 3,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
type: 'rat',
|
||||
pos: { x: 2, y: 2 },
|
||||
speed: 100,
|
||||
type: "rat",
|
||||
pos: { x: 3, y: 1 },
|
||||
speed: 10,
|
||||
energy: 0,
|
||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0, level: 1, exp: 0, expToNextLevel: 0, statPoints: 0, skillPoints: 0, strength: 0, dexterity: 0, intelligence: 0, passiveNodes: [] }
|
||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
|
||||
});
|
||||
|
||||
(renderer as any).visible[2 * mockWorld.width + 2] = 1;
|
||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
||||
mockScene.add.sprite.mockClear();
|
||||
|
||||
renderer.render([]);
|
||||
|
||||
Reference in New Issue
Block a user