Change black empty tile to grass and make it destructable

This commit is contained in:
Peter Stockings
2026-01-05 20:59:33 +11:00
parent a7091c70c6
commit ecf58dded1
8 changed files with 150 additions and 43 deletions

View File

@@ -6,6 +6,8 @@ export interface AnimationConfig {
repeat: number; repeat: number;
} }
export const GAME_CONFIG = { export const GAME_CONFIG = {
player: { player: {
initialStats: { initialStats: {
@@ -110,16 +112,8 @@ export const GAME_CONFIG = {
visibleStrengthFactor: 0.65 visibleStrengthFactor: 0.65
}, },
terrain: {
empty: 1,
wall: 4,
water: 63,
emptyDeco: 24,
wallDeco: 12,
exit: 8
},
ui: { ui: {
// ... rest of content ...
minimapPanelWidth: 340, minimapPanelWidth: 340,
minimapPanelHeight: 220, minimapPanelHeight: 220,
minimapPadding: 20, minimapPadding: 20,

49
src/core/terrain.ts Normal file
View File

@@ -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<number, TileBehavior> = {
[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;
}

View File

@@ -63,6 +63,28 @@ describe('Combat Simulation', () => {
expect(orb).toBeDefined(); expect(orb).toBeDefined();
expect(orb!.id).toBe(3); expect(orb!.id).toBe(3);
}); });
it("should destruction tile when walking on destructible-by-walk tile", () => {
const actors = new Map<EntityId, Actor>();
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", () => { describe("decideEnemyAction - AI Logic", () => {

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic'; import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
import { type World, type Tile } from '../../core/types'; import { type World, type Tile } from '../../core/types';
import { GAME_CONFIG } from '../../core/config/GameConfig'; import { TileType } from '../../core/terrain';
describe('World Utilities', () => { describe('World Utilities', () => {
@@ -46,9 +46,9 @@ describe('World Utilities', () => {
describe('isWall', () => { describe('isWall', () => {
it('should return true for wall tiles', () => { it('should return true for wall tiles', () => {
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0 tiles[0] = TileType.WALL; // wall at 0,0
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5 tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
@@ -58,7 +58,7 @@ describe('World Utilities', () => {
}); });
it('should return false for floor tiles', () => { 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); const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false); expect(isWall(world, 3, 3)).toBe(false);
@@ -76,8 +76,8 @@ describe('World Utilities', () => {
describe('isBlocked', () => { describe('isBlocked', () => {
it('should return true for walls', () => { it('should return true for walls', () => {
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5 tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);

View File

@@ -1,6 +1,7 @@
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types"; 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 { findPathAStar } from "../world/pathfinding";
import { GAME_CONFIG } from "../../core/config/GameConfig"; import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityManager } from "../EntityManager"; 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 to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; 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) { if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events, em); handleExpCollection(w, actor, events, em);
} }

View File

@@ -1,4 +1,5 @@
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types"; 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 { idx } from "./world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig"; import { GAME_CONFIG } from "../../core/config/GameConfig";
import { seededRandom } from "../../core/math"; import { seededRandom } from "../../core/math";
@@ -20,7 +21,7 @@ interface Room {
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } { export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } {
const width = GAME_CONFIG.map.width; const width = GAME_CONFIG.map.width;
const height = GAME_CONFIG.map.height; 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); 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) => { dungeon.create((x: number, y: number, value: number) => {
if (value === 0) { if (value === 0) {
// 0 = floor, 1 = wall // 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 y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) { for (let x = 1; x < width - 1; x++) {
const idx = y * width + 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); const cluster = floodFill(width, height, tiles, x, y, visited);
// Only consider clusters larger than 20 tiles // 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) { for (const { nx, ny } of neighbors) {
if (nx >= 0 && nx < width && ny >= 0 && ny < height) { if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx; 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); queue.push(nIdx);
} }
} }
@@ -220,53 +221,53 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
const world = { width, height }; const world = { width, height };
// Set exit tile // 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 // Use Simplex noise for natural-looking grass distribution
const waterNoise = new ROT.Noise.Simplex(); const grassNoise = new ROT.Noise.Simplex();
const decorationNoise = new ROT.Noise.Simplex(); const decorationNoise = new ROT.Noise.Simplex();
// Offset noise to get different patterns for water vs decorations // Offset noise to get different patterns for grass vs decorations
const waterOffset = random() * 1000; const grassOffset = random() * 1000;
const decorOffset = random() * 1000; const decorOffset = random() * 1000;
for (let y = 0; y < height; y++) { for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
const i = idx(world as any, x, y); const i = idx(world as any, x, y);
if (tiles[i] === GAME_CONFIG.terrain.empty) { if (tiles[i] === TileType.EMPTY) {
// Water lakes: use noise to create organic shapes // Grass patches: use noise to create organic shapes
const waterValue = waterNoise.get((x + waterOffset) / 15, (y + waterOffset) / 15); const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15);
// Create water patches where noise is above threshold // Create grass patches where noise is above threshold
if (waterValue > 0.35) { if (grassValue > 0.35) {
tiles[i] = GAME_CONFIG.terrain.water; tiles[i] = TileType.GRASS;
} else { } else {
// Floor decorations (moss/grass): clustered distribution // Floor decorations (moss/rocks): clustered distribution
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8); const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
// Dense clusters where noise is high // Dense clusters where noise is high
if (decoValue > 0.5) { if (decoValue > 0.5) {
tiles[i] = GAME_CONFIG.terrain.emptyDeco; tiles[i] = TileType.EMPTY_DECO;
} else if (decoValue > 0.3 && random() < 0.3) { } else if (decoValue > 0.3 && random() < 0.3) {
// Sparse decorations at medium noise levels // 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 y = 0; y < height - 1; y++) {
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
const i = idx(world as any, x, y); const i = idx(world as any, x, y);
const nextY = idx(world as any, x, y + 1); const nextY = idx(world as any, x, y + 1);
if (tiles[i] === GAME_CONFIG.terrain.wall && if (tiles[i] === TileType.WALL &&
tiles[nextY] === GAME_CONFIG.terrain.water && tiles[nextY] === TileType.GRASS &&
random() < 0.25) { random() < 0.25) {
tiles[i] = GAME_CONFIG.terrain.wallDeco; tiles[i] = TileType.WALL_DECO;
} }
} }
} }

View File

@@ -1,8 +1,8 @@
import type { World, EntityId } from "../../core/types"; 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"; import { type EntityManager } from "../EntityManager";
export function inBounds(w: World, x: number, y: number): boolean { export function inBounds(w: World, x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < w.width && y < w.height; 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 { 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)]; 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 { export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
if (!inBounds(w, x, y)) return true; if (!inBounds(w, x, y)) return true;
if (isWall(w, x, y)) return true; if (isBlockingTile(w, x, y)) return true;
if (em) { if (em) {
return em.isOccupied(x, y, "exp_orb"); return em.isOccupied(x, y, "exp_orb");

View File

@@ -110,6 +110,15 @@ export class DungeonRenderer {
// Update Tiles // Update Tiles
this.layer.forEachTile(tile => { this.layer.forEachTile(tile => {
const i = idx(this.world, tile.x, tile.y); 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 isSeen = seen[i] === 1;
const isVis = visible[i] === 1; const isVis = visible[i] === 1;