From ecf58dded1a551fee9527c89ab17c0149778ef07 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 20:59:33 +1100 Subject: [PATCH] Change black empty tile to grass and make it destructable --- src/core/config/GameConfig.ts | 12 ++---- src/core/terrain.ts | 49 +++++++++++++++++++++++++ src/engine/__tests__/simulation.test.ts | 22 +++++++++++ src/engine/__tests__/world.test.ts | 14 +++---- src/engine/simulation/simulation.ts | 13 ++++++- src/engine/world/generator.ts | 45 ++++++++++++----------- src/engine/world/world-logic.ts | 29 +++++++++++++-- src/rendering/DungeonRenderer.ts | 9 +++++ 8 files changed, 150 insertions(+), 43 deletions(-) create mode 100644 src/core/terrain.ts diff --git a/src/core/config/GameConfig.ts b/src/core/config/GameConfig.ts index 8860725..bc0f045 100644 --- a/src/core/config/GameConfig.ts +++ b/src/core/config/GameConfig.ts @@ -6,6 +6,8 @@ export interface AnimationConfig { repeat: number; } + + export const GAME_CONFIG = { player: { initialStats: { @@ -110,16 +112,8 @@ export const GAME_CONFIG = { visibleStrengthFactor: 0.65 }, - terrain: { - empty: 1, - wall: 4, - water: 63, - emptyDeco: 24, - wallDeco: 12, - exit: 8 - }, - ui: { + // ... rest of content ... minimapPanelWidth: 340, minimapPanelHeight: 220, minimapPadding: 20, diff --git a/src/core/terrain.ts b/src/core/terrain.ts new file mode 100644 index 0000000..47249d3 --- /dev/null +++ b/src/core/terrain.ts @@ -0,0 +1,49 @@ +export const TileType = { + EMPTY: 1, + WALL: 4, + GRASS: 15, + EMPTY_DECO: 24, + WALL_DECO: 12, + EXIT: 8, + WATER: 63 // Unused but kept for safety/legacy +} as const; + +export type TileType = typeof TileType[keyof typeof TileType]; + +export interface TileBehavior { + id: TileType; + isBlocking: boolean; + isDestructible: boolean; + isDestructibleByWalk?: boolean; + destructsTo?: TileType; +} + +export const TILE_DEFINITIONS: Record = { + [TileType.EMPTY]: { id: TileType.EMPTY, isBlocking: false, isDestructible: false }, + [TileType.WALL]: { id: TileType.WALL, isBlocking: true, isDestructible: false }, + [TileType.GRASS]: { id: TileType.GRASS, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, destructsTo: TileType.EMPTY }, + [TileType.EMPTY_DECO]: { id: TileType.EMPTY_DECO, isBlocking: false, isDestructible: false }, + [TileType.WALL_DECO]: { id: TileType.WALL_DECO, isBlocking: true, isDestructible: false }, + [TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false }, + [TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false } +}; + +export function isBlocking(tile: number): boolean { + const def = TILE_DEFINITIONS[tile]; + return def ? def.isBlocking : false; +} + +export function isDestructible(tile: number): boolean { + const def = TILE_DEFINITIONS[tile]; + return def ? def.isDestructible : false; +} + +export function isDestructibleByWalk(tile: number): boolean { + const def = TILE_DEFINITIONS[tile]; + return def ? !!def.isDestructibleByWalk : false; +} + +export function getDestructionResult(tile: number): number | undefined { + const def = TILE_DEFINITIONS[tile]; + return def ? def.destructsTo : undefined; +} diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index d01bb00..0a16cd9 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -63,6 +63,28 @@ describe('Combat Simulation', () => { expect(orb).toBeDefined(); expect(orb!.id).toBe(3); }); + + it("should destruction tile when walking on destructible-by-walk tile", () => { + const actors = new Map(); + actors.set(1, { + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats() + } as any); + + const world = createTestWorld(actors); + // tile at 4,3 is grass (15) which is destructible by walk + const grassIdx = 3 * 10 + 4; + world.tiles[grassIdx] = 15; // TileType.GRASS + + entityManager = new EntityManager(world); + applyAction(world, 1, { type: "move", dx: 1, dy: 0 }, entityManager); + + // Player moved to 4,3 + const player = world.actors.get(1); + expect(player!.pos).toEqual({ x: 4, y: 3 }); + + // Tile should effectively be destroyed (turned to empty/1) + expect(world.tiles[grassIdx]).toBe(1); // TileType.EMPTY + }); }); describe("decideEnemyAction - AI Logic", () => { diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index adb4983..0d2e02e 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { idx, inBounds, isWall, isBlocked } from '../world/world-logic'; import { type World, type Tile } from '../../core/types'; -import { GAME_CONFIG } from '../../core/config/GameConfig'; +import { TileType } from '../../core/terrain'; describe('World Utilities', () => { @@ -46,9 +46,9 @@ describe('World Utilities', () => { describe('isWall', () => { it('should return true for wall tiles', () => { - const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); - tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0 - tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5 + const tiles: Tile[] = new Array(100).fill(TileType.EMPTY); + tiles[0] = TileType.WALL; // wall at 0,0 + tiles[55] = TileType.WALL; // wall at 5,5 const world = createTestWorld(10, 10, tiles); @@ -58,7 +58,7 @@ describe('World Utilities', () => { }); it('should return false for floor tiles', () => { - const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); + const tiles: Tile[] = new Array(100).fill(TileType.EMPTY); const world = createTestWorld(10, 10, tiles); expect(isWall(world, 3, 3)).toBe(false); @@ -76,8 +76,8 @@ describe('World Utilities', () => { describe('isBlocked', () => { it('should return true for walls', () => { - const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); - tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5 + const tiles: Tile[] = new Array(100).fill(TileType.EMPTY); + tiles[55] = TileType.WALL; // wall at 5,5 const world = createTestWorld(10, 10, tiles); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 7e95347..2c5f011 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -1,6 +1,7 @@ import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types"; -import { isBlocked, inBounds, isWall } from "../world/world-logic"; +import { isBlocked, inBounds, isWall, tryDestructTile } from "../world/world-logic"; +import { isDestructibleByWalk } from "../../core/terrain"; import { findPathAStar } from "../world/pathfinding"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { type EntityManager } from "../EntityManager"; @@ -105,6 +106,16 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, const to = { ...actor.pos }; const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; + // Check for "destructible by walk" tiles (e.g. grass) + // We check the tile at the *new* position + const tileIdx = ny * w.width + nx; + const tile = w.tiles[tileIdx]; + if (isDestructibleByWalk(tile)) { + tryDestructTile(w, nx, ny); + // Optional: Add an event if we want visual feedback immediately, + // but the renderer usually handles map updates automatically or next frame + } + if (actor.category === "combatant" && actor.isPlayer) { handleExpCollection(w, actor, events, em); } diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index de8183c..528e2b1 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -1,4 +1,5 @@ import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types"; +import { TileType } from "../../core/terrain"; import { idx } from "./world-logic"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { seededRandom } from "../../core/math"; @@ -20,7 +21,7 @@ interface Room { export function generateWorld(floor: 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(GAME_CONFIG.terrain.wall); + const tiles: Tile[] = new Array(width * height).fill(TileType.WALL); const random = seededRandom(floor * 12345); @@ -103,7 +104,7 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb dungeon.create((x: number, y: number, value: number) => { if (value === 0) { // 0 = floor, 1 = wall - tiles[y * width + x] = GAME_CONFIG.terrain.empty; + tiles[y * width + x] = TileType.EMPTY; } }); @@ -148,7 +149,7 @@ function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Roo for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = y * width + x; - if (tiles[idx] === GAME_CONFIG.terrain.empty && !visited.has(idx)) { + if (tiles[idx] === TileType.EMPTY && !visited.has(idx)) { const cluster = floodFill(width, height, tiles, x, y, visited); // Only consider clusters larger than 20 tiles @@ -206,7 +207,7 @@ function floodFill(width: number, height: number, tiles: Tile[], startX: number, for (const { nx, ny } of neighbors) { if (nx >= 0 && nx < width && ny >= 0 && ny < height) { const nIdx = ny * width + nx; - if (tiles[nIdx] === GAME_CONFIG.terrain.empty && !visited.has(nIdx)) { + if (tiles[nIdx] === TileType.EMPTY && !visited.has(nIdx)) { queue.push(nIdx); } } @@ -220,53 +221,53 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu const world = { width, height }; // Set exit tile - tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit; + tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT; - // Use Simplex noise for natural-looking water distribution - const waterNoise = new ROT.Noise.Simplex(); + // Use Simplex noise for natural-looking grass distribution + const grassNoise = new ROT.Noise.Simplex(); const decorationNoise = new ROT.Noise.Simplex(); - // Offset noise to get different patterns for water vs decorations - const waterOffset = random() * 1000; + // Offset noise to get different patterns for grass vs decorations + const grassOffset = random() * 1000; const decorOffset = random() * 1000; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = idx(world as any, x, y); - if (tiles[i] === GAME_CONFIG.terrain.empty) { - // Water lakes: use noise to create organic shapes - const waterValue = waterNoise.get((x + waterOffset) / 15, (y + waterOffset) / 15); + if (tiles[i] === TileType.EMPTY) { + // Grass patches: use noise to create organic shapes + const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15); - // Create water patches where noise is above threshold - if (waterValue > 0.35) { - tiles[i] = GAME_CONFIG.terrain.water; + // Create grass patches where noise is above threshold + if (grassValue > 0.35) { + tiles[i] = TileType.GRASS; } else { - // Floor decorations (moss/grass): clustered distribution + // Floor decorations (moss/rocks): clustered distribution const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8); // Dense clusters where noise is high if (decoValue > 0.5) { - tiles[i] = GAME_CONFIG.terrain.emptyDeco; + tiles[i] = TileType.EMPTY_DECO; } else if (decoValue > 0.3 && random() < 0.3) { // Sparse decorations at medium noise levels - tiles[i] = GAME_CONFIG.terrain.emptyDeco; + tiles[i] = TileType.EMPTY_DECO; } } } } } - // Wall decorations (algae near water) + // Wall decorations (moss near grass) 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 && + if (tiles[i] === TileType.WALL && + tiles[nextY] === TileType.GRASS && random() < 0.25) { - tiles[i] = GAME_CONFIG.terrain.wallDeco; + tiles[i] = TileType.WALL_DECO; } } } diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index b5a7cba..3fe4af7 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -1,8 +1,8 @@ import type { World, EntityId } from "../../core/types"; -import { GAME_CONFIG } from "../../core/config/GameConfig"; - +import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain"; import { type EntityManager } from "../EntityManager"; + export function inBounds(w: World, x: number, y: number): boolean { return x >= 0 && y >= 0 && x < w.width && y < w.height; } @@ -12,13 +12,34 @@ export function idx(w: World, x: number, y: number): number { } export function isWall(w: World, x: number, y: number): boolean { + // Alias for isBlocking for backward compatibility + return isBlockingTile(w, x, y); +} + +export function isBlockingTile(w: World, x: number, y: number): boolean { const tile = w.tiles[idx(w, x, y)]; - return tile === GAME_CONFIG.terrain.wall || tile === GAME_CONFIG.terrain.wallDeco; + return isBlocking(tile); +} + +export function tryDestructTile(w: World, x: number, y: number): boolean { + if (!inBounds(w, x, y)) return false; + + const i = idx(w, x, y); + const tile = w.tiles[i]; + + if (isDestructible(tile)) { + const nextTile = getDestructionResult(tile); + if (nextTile !== undefined) { + w.tiles[i] = nextTile; + return true; + } + } + return false; } export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean { if (!inBounds(w, x, y)) return true; - if (isWall(w, x, y)) return true; + if (isBlockingTile(w, x, y)) return true; if (em) { return em.isOccupied(x, y, "exp_orb"); diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index d423f7d..999331e 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -110,6 +110,15 @@ export class DungeonRenderer { // Update Tiles this.layer.forEachTile(tile => { const i = idx(this.world, tile.x, tile.y); + const worldTile = this.world.tiles[i]; + + // Sync visual tile with logical tile (e.g. if grass was destroyed) + if (tile.index !== worldTile) { + // We can safely update the index property for basic tile switching + // If we needed to change collision properties, we'd use putTileAt + tile.index = worldTile; + } + const isSeen = seen[i] === 1; const isVis = visible[i] === 1;