Change black empty tile to grass and make it destructable
This commit is contained in:
@@ -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
49
src/core/terrain.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user