Compare commits
5 Commits
ecf58dded1
...
4b9dfa98b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9dfa98b5 | ||
|
|
b3954a6408 | ||
|
|
b35cf5a964 | ||
|
|
a01d4abdf7 | ||
|
|
39528d297e |
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Rogue
|
||||||
|
|
||||||
|
A roguelike dungeon crawler built with TypeScript, Phaser, and Rot.js.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Follow these instructions to get the project up and running on your local machine.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
This project uses [Bun](https://bun.sh/) as its runtime and package manager. Ensure you have Bun installed on your system.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Clone the repository and install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Game
|
||||||
|
|
||||||
|
Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing the Game
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to the URL shown in the terminal. typically:
|
||||||
|
|
||||||
|
[http://localhost:5173](http://localhost:5173)
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
We strive to maintain a high quality of code. Please follow these guidelines when contributing.
|
||||||
|
|
||||||
|
### Making Changes
|
||||||
|
|
||||||
|
1. **Create a Feature Branch**: Always create a new branch for your changes.
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/my-new-feature
|
||||||
|
```
|
||||||
|
2. **Run Verification**: Before merging your changes, run the verification script to ensure type safety and pass all tests.
|
||||||
|
```bash
|
||||||
|
bun run verify
|
||||||
|
```
|
||||||
|
3. **Add Tests**: Ensure that new functionality is covered by unit tests.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
To run the test suite manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
The source code is organized as follows:
|
||||||
|
|
||||||
|
- `src/core`: Contains core game logic, configuration, and type definitions.
|
||||||
|
- `src/engine`: Handles game systems like simulation, turn management, and world generation.
|
||||||
|
- `src/rendering`: Manages visual aspects using Phaser, including the renderer and FOV.
|
||||||
|
- `src/scenes`: Defines the different Phaser scenes (e.g., Game, Menu, Preload).
|
||||||
|
- `src/ui`: Contains User Interface components and logic.
|
||||||
|
|
||||||
|
## Built With
|
||||||
|
|
||||||
|
- [Phaser](https://phaser.io/) - HTML5 Game Framework
|
||||||
|
- [Rot.js](https://ondras.github.io/rot.js/hp/) - Roguelike Toolkit
|
||||||
|
- [Vite](https://vitejs.dev/) - Frontend Tooling
|
||||||
|
- [TypeScript](https://www.typescriptlang.org/) - Typed JavaScript
|
||||||
|
- [Vitest](https://vitest.dev/) - Unit Testing Framework
|
||||||
39
src/core/__tests__/math.test.ts
Normal file
39
src/core/__tests__/math.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { seededRandom, manhattan, lerp } from '../math';
|
||||||
|
|
||||||
|
describe('Math Utilities', () => {
|
||||||
|
describe('seededRandom', () => {
|
||||||
|
it('should return consistent results for the same seed', () => {
|
||||||
|
const rng1 = seededRandom(12345);
|
||||||
|
const rng2 = seededRandom(12345);
|
||||||
|
|
||||||
|
expect(rng1()).toBe(rng2());
|
||||||
|
expect(rng1()).toBe(rng2());
|
||||||
|
expect(rng1()).toBe(rng2());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return different results for different seeds', () => {
|
||||||
|
const rng1 = seededRandom(12345);
|
||||||
|
const rng2 = seededRandom(67890);
|
||||||
|
|
||||||
|
expect(rng1()).not.toBe(rng2());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('manhattan', () => {
|
||||||
|
it('should calculate correct distance', () => {
|
||||||
|
expect(manhattan({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7);
|
||||||
|
expect(manhattan({ x: 1, y: 1 }, { x: 4, y: 5 })).toBe(7);
|
||||||
|
expect(manhattan({ x: -1, y: -1 }, { x: -2, y: -2 })).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lerp', () => {
|
||||||
|
it('should interpolate correctly', () => {
|
||||||
|
expect(lerp(0, 10, 0.5)).toBe(5);
|
||||||
|
expect(lerp(0, 10, 0)).toBe(0);
|
||||||
|
expect(lerp(0, 10, 1)).toBe(10);
|
||||||
|
expect(lerp(10, 20, 0.5)).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
54
src/core/__tests__/terrain.test.ts
Normal file
54
src/core/__tests__/terrain.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
TileType,
|
||||||
|
isBlocking,
|
||||||
|
isDestructible,
|
||||||
|
blocksSight,
|
||||||
|
getDestructionResult,
|
||||||
|
isDestructibleByWalk
|
||||||
|
} from '../terrain';
|
||||||
|
|
||||||
|
describe('Terrain', () => {
|
||||||
|
describe('Tile Definitions', () => {
|
||||||
|
it('should correctly identify blocking tiles', () => {
|
||||||
|
expect(isBlocking(TileType.WALL)).toBe(true);
|
||||||
|
expect(isBlocking(TileType.WALL_DECO)).toBe(true);
|
||||||
|
expect(isBlocking(TileType.WATER)).toBe(true);
|
||||||
|
|
||||||
|
expect(isBlocking(TileType.EMPTY)).toBe(false);
|
||||||
|
expect(isBlocking(TileType.GRASS)).toBe(false);
|
||||||
|
expect(isBlocking(TileType.EXIT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify destructible tiles', () => {
|
||||||
|
expect(isDestructible(TileType.GRASS)).toBe(true);
|
||||||
|
expect(isDestructible(TileType.DOOR_CLOSED)).toBe(true);
|
||||||
|
|
||||||
|
expect(isDestructible(TileType.WALL)).toBe(false);
|
||||||
|
expect(isDestructible(TileType.EMPTY)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify tiles blocking sight', () => {
|
||||||
|
expect(blocksSight(TileType.WALL)).toBe(true);
|
||||||
|
expect(blocksSight(TileType.WALL_DECO)).toBe(true);
|
||||||
|
expect(blocksSight(TileType.DOOR_CLOSED)).toBe(true);
|
||||||
|
expect(blocksSight(TileType.GRASS)).toBe(true); // Grass blocks vision in this game logic
|
||||||
|
|
||||||
|
expect(blocksSight(TileType.EMPTY)).toBe(false);
|
||||||
|
expect(blocksSight(TileType.EXIT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct destruction result', () => {
|
||||||
|
expect(getDestructionResult(TileType.GRASS)).toBe(TileType.GRASS_SAPLINGS);
|
||||||
|
expect(getDestructionResult(TileType.DOOR_CLOSED)).toBe(TileType.DOOR_OPEN);
|
||||||
|
|
||||||
|
expect(getDestructionResult(TileType.WALL)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify tiles destructible by walk', () => {
|
||||||
|
expect(isDestructibleByWalk(TileType.GRASS)).toBe(true);
|
||||||
|
expect(isDestructibleByWalk(TileType.DOOR_CLOSED)).toBe(true);
|
||||||
|
expect(isDestructibleByWalk(TileType.WALL)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/core/__tests__/utils.test.ts
Normal file
12
src/core/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { key } from '../utils';
|
||||||
|
|
||||||
|
describe('Utils', () => {
|
||||||
|
describe('key', () => {
|
||||||
|
it('should generate correct key string', () => {
|
||||||
|
expect(key(1, 2)).toBe('1,2');
|
||||||
|
expect(key(0, 0)).toBe('0,0');
|
||||||
|
expect(key(-5, 10)).toBe('-5,10');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,13 @@ export const TileType = {
|
|||||||
EMPTY: 1,
|
EMPTY: 1,
|
||||||
WALL: 4,
|
WALL: 4,
|
||||||
GRASS: 15,
|
GRASS: 15,
|
||||||
|
GRASS_SAPLINGS: 2,
|
||||||
EMPTY_DECO: 24,
|
EMPTY_DECO: 24,
|
||||||
WALL_DECO: 12,
|
WALL_DECO: 12,
|
||||||
EXIT: 8,
|
EXIT: 8,
|
||||||
WATER: 63 // Unused but kept for safety/legacy
|
WATER: 63, // Unused but kept for safety/legacy
|
||||||
|
DOOR_CLOSED: 5,
|
||||||
|
DOOR_OPEN: 6
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TileType = typeof TileType[keyof typeof TileType];
|
export type TileType = typeof TileType[keyof typeof TileType];
|
||||||
@@ -15,17 +18,21 @@ export interface TileBehavior {
|
|||||||
isBlocking: boolean;
|
isBlocking: boolean;
|
||||||
isDestructible: boolean;
|
isDestructible: boolean;
|
||||||
isDestructibleByWalk?: boolean;
|
isDestructibleByWalk?: boolean;
|
||||||
|
blocksVision?: boolean;
|
||||||
destructsTo?: TileType;
|
destructsTo?: TileType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
|
export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
|
||||||
[TileType.EMPTY]: { id: TileType.EMPTY, isBlocking: false, isDestructible: false },
|
[TileType.EMPTY]: { id: TileType.EMPTY, isBlocking: false, isDestructible: false },
|
||||||
[TileType.WALL]: { id: TileType.WALL, isBlocking: true, 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.GRASS]: { id: TileType.GRASS, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.GRASS_SAPLINGS },
|
||||||
|
[TileType.GRASS_SAPLINGS]: { id: TileType.GRASS_SAPLINGS, isBlocking: false, isDestructible: false },
|
||||||
[TileType.EMPTY_DECO]: { id: TileType.EMPTY_DECO, isBlocking: false, isDestructible: false },
|
[TileType.EMPTY_DECO]: { id: TileType.EMPTY_DECO, isBlocking: false, isDestructible: false },
|
||||||
[TileType.WALL_DECO]: { id: TileType.WALL_DECO, isBlocking: true, isDestructible: false },
|
[TileType.WALL_DECO]: { id: TileType.WALL_DECO, isBlocking: true, isDestructible: false },
|
||||||
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
|
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
|
||||||
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false }
|
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
|
||||||
|
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
|
||||||
|
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isBlocking(tile: number): boolean {
|
export function isBlocking(tile: number): boolean {
|
||||||
@@ -43,6 +50,11 @@ export function isDestructibleByWalk(tile: number): boolean {
|
|||||||
return def ? !!def.isDestructibleByWalk : false;
|
return def ? !!def.isDestructibleByWalk : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function blocksSight(tile: number): boolean {
|
||||||
|
const def = TILE_DEFINITIONS[tile];
|
||||||
|
return def ? (def.isBlocking || !!def.blocksVision) : false;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDestructionResult(tile: number): number | undefined {
|
export function getDestructionResult(tile: number): number | undefined {
|
||||||
const def = TILE_DEFINITIONS[tile];
|
const def = TILE_DEFINITIONS[tile];
|
||||||
return def ? def.destructsTo : undefined;
|
return def ? def.destructsTo : undefined;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type Tile = number;
|
|||||||
export type EnemyType = "rat" | "bat";
|
export type EnemyType = "rat" | "bat";
|
||||||
export type ActorType = "player" | EnemyType;
|
export type ActorType = "player" | EnemyType;
|
||||||
|
|
||||||
export type EnemyAIState = "wandering" | "alerted" | "pursuing";
|
export type EnemyAIState = "wandering" | "alerted" | "pursuing" | "searching";
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
| { type: "move"; dx: number; dy: number }
|
| { type: "move"; dx: number; dy: number }
|
||||||
|
|||||||
@@ -174,5 +174,53 @@ describe('World Generator', () => {
|
|||||||
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
||||||
expect(avgHp5).toBeGreaterThan(avgHp1);
|
expect(avgHp5).toBeGreaterThan(avgHp1);
|
||||||
});
|
});
|
||||||
|
it('should generate doors on dungeon floors', () => {
|
||||||
|
const runState = {
|
||||||
|
stats: {
|
||||||
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
|
passiveNodes: []
|
||||||
|
},
|
||||||
|
inventory: { gold: 0, items: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a few worlds to ensure we hit the 50% door chance at least once
|
||||||
|
let foundDoor = false;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const { world } = generateWorld(1, runState); // Floor 1 is Uniform (Dungeon)
|
||||||
|
if (world.tiles.some(t => t === 5 || t === 6)) { // 5=DOOR_CLOSED, 6=DOOR_OPEN
|
||||||
|
foundDoor = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(foundDoor).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ensure player spawns on safe tile (not grass)', () => {
|
||||||
|
const runState = {
|
||||||
|
stats: {
|
||||||
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
|
passiveNodes: []
|
||||||
|
},
|
||||||
|
inventory: { gold: 0, items: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate multiple worlds to stress test spawn placement
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const { world, playerId } = generateWorld(1, runState);
|
||||||
|
const player = world.actors.get(playerId)!;
|
||||||
|
|
||||||
|
// Check tile under player
|
||||||
|
const tileIdx = player.pos.y * world.width + player.pos.x;
|
||||||
|
const tile = world.tiles[tileIdx];
|
||||||
|
|
||||||
|
// Should be EMPTY (1), specifically NOT GRASS (15) which blocks vision
|
||||||
|
expect(tile).toBe(1); // TileType.EMPTY
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
83
src/engine/__tests__/pathfinding.test.ts
Normal file
83
src/engine/__tests__/pathfinding.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { findPathAStar } from '../world/pathfinding';
|
||||||
|
import { type World } from '../../core/types';
|
||||||
|
import { TileType } from '../../core/terrain';
|
||||||
|
|
||||||
|
describe('Pathfinding', () => {
|
||||||
|
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tiles: new Array(width * height).fill(tileType),
|
||||||
|
actors: new Map(),
|
||||||
|
exit: { x: 0, y: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find a path between two reachable points', () => {
|
||||||
|
const world = createTestWorld(10, 10);
|
||||||
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
|
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
|
|
||||||
|
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
|
||||||
|
expect(path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(path[3]).toEqual({ x: 0, y: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array if target is a wall', () => {
|
||||||
|
const world = createTestWorld(10, 10);
|
||||||
|
world.tiles[30] = TileType.WALL; // Wall at 0,3
|
||||||
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
|
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
|
|
||||||
|
expect(path).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array if no path exists', () => {
|
||||||
|
const world = createTestWorld(10, 10);
|
||||||
|
// Create a wall blockage
|
||||||
|
for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL;
|
||||||
|
|
||||||
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
|
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
|
||||||
|
|
||||||
|
expect(path).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect ignoreBlockedTarget option', () => {
|
||||||
|
const world = createTestWorld(10, 10);
|
||||||
|
// Place an actor at target
|
||||||
|
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat' } as any);
|
||||||
|
|
||||||
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
|
// Without option, it should be blocked (because actor is there)
|
||||||
|
// Wait, default pathfinding might treat actors as blocking unless specified.
|
||||||
|
// Let's check `isBlocked` usage in `pathfinding.ts`.
|
||||||
|
// It calls `isBlocked` which checks actors.
|
||||||
|
|
||||||
|
// However, findPathAStar has logic:
|
||||||
|
// if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
||||||
|
|
||||||
|
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
|
expect(pathBlocked).toEqual([]);
|
||||||
|
|
||||||
|
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true });
|
||||||
|
expect(pathIgnored.length).toBeGreaterThan(0);
|
||||||
|
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect ignoreSeen option', () => {
|
||||||
|
const world = createTestWorld(10, 10);
|
||||||
|
const seen = new Uint8Array(100).fill(0); // Nothing seen
|
||||||
|
|
||||||
|
// Without ignoreSeen, should fail because target/path is unseen
|
||||||
|
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
|
expect(pathUnseen).toEqual([]);
|
||||||
|
|
||||||
|
// With ignoreSeen, should succeed
|
||||||
|
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
|
||||||
|
expect(pathSeenIgnored.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { applyAction, decideEnemyAction } from '../simulation/simulation';
|
import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||||
import { EntityManager } from '../EntityManager';
|
import { EntityManager } from '../EntityManager';
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ describe('Combat Simulation', () => {
|
|||||||
const player = world.actors.get(1);
|
const player = world.actors.get(1);
|
||||||
expect(player!.pos).toEqual({ x: 4, y: 3 });
|
expect(player!.pos).toEqual({ x: 4, y: 3 });
|
||||||
|
|
||||||
// Tile should effectively be destroyed (turned to empty/1)
|
// Tile should effectively be destroyed (turned to saplings/2)
|
||||||
expect(world.tiles[grassIdx]).toBe(1); // TileType.EMPTY
|
expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,5 +126,115 @@ describe('Combat Simulation', () => {
|
|||||||
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should transition to alerted when spotting player", () => {
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats() } as any;
|
||||||
|
const enemy = {
|
||||||
|
id: 2,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
stats: createTestStats(),
|
||||||
|
aiState: "wandering"
|
||||||
|
} as any;
|
||||||
|
actors.set(1, player);
|
||||||
|
actors.set(2, enemy);
|
||||||
|
const world = createTestWorld(actors);
|
||||||
|
|
||||||
|
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||||
|
|
||||||
|
expect(enemy.aiState).toBe("alerted");
|
||||||
|
expect(decision.justAlerted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition from pursuing to searching when sight is lost", () => {
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
// Player far away (unseen)
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats() } as any;
|
||||||
|
const enemy = {
|
||||||
|
id: 2,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
stats: createTestStats(),
|
||||||
|
aiState: "pursuing", // Currently pursuing
|
||||||
|
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||||
|
} as any;
|
||||||
|
actors.set(1, player);
|
||||||
|
actors.set(2, enemy);
|
||||||
|
const world = createTestWorld(actors);
|
||||||
|
|
||||||
|
// Should switch to searching because can't see player
|
||||||
|
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||||
|
|
||||||
|
expect(enemy.aiState).toBe("searching");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition from searching to alerted when sight regained", () => {
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
// Player adjacent (visible)
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 1, y: 0 }, stats: createTestStats() } as any;
|
||||||
|
const enemy = {
|
||||||
|
id: 2,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
stats: createTestStats(),
|
||||||
|
aiState: "searching",
|
||||||
|
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||||
|
} as any;
|
||||||
|
actors.set(1, player);
|
||||||
|
actors.set(2, enemy);
|
||||||
|
const world = createTestWorld(actors);
|
||||||
|
|
||||||
|
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||||
|
|
||||||
|
expect(enemy.aiState).toBe("alerted");
|
||||||
|
expect(decision.justAlerted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition from searching to wandering when reached target", () => {
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
// Player far away (unseen) - Manhattan dist > 8
|
||||||
|
// Enemy at 9,9. Player at 0,0. Dist = 18.
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats() } as any;
|
||||||
|
const enemy = {
|
||||||
|
id: 2,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
pos: { x: 9, y: 9 }, // At target
|
||||||
|
stats: createTestStats(),
|
||||||
|
aiState: "searching",
|
||||||
|
lastKnownPlayerPos: { x: 9, y: 9 }
|
||||||
|
} as any;
|
||||||
|
actors.set(1, player);
|
||||||
|
actors.set(2, enemy);
|
||||||
|
const world = createTestWorld(actors);
|
||||||
|
|
||||||
|
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||||
|
|
||||||
|
expect(enemy.aiState).toBe("wandering");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepUntilPlayerTurn", () => {
|
||||||
|
it("should process enemy turns", () => {
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
// Player is slow, enemy is fast. Enemy should move before player returns.
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats() } as any;
|
||||||
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats() } as any;
|
||||||
|
|
||||||
|
actors.set(1, player);
|
||||||
|
actors.set(2, enemy);
|
||||||
|
const world = createTestWorld(actors);
|
||||||
|
const em = new EntityManager(world);
|
||||||
|
|
||||||
|
const result = stepUntilPlayerTurn(world, 1, em);
|
||||||
|
|
||||||
|
// Enemy should have taken at least one action
|
||||||
|
expect(result.events.length).toBeGreaterThan(0);
|
||||||
|
expect(result.awaitingPlayerId).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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, tryDestructTile, isPlayerOnExit } from '../world/world-logic';
|
||||||
import { type World, type Tile } from '../../core/types';
|
import { type World, type Tile } from '../../core/types';
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
|
||||||
@@ -114,4 +114,65 @@ describe('World Utilities', () => {
|
|||||||
expect(isBlocked(world, 10, 10)).toBe(true);
|
expect(isBlocked(world, 10, 10)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('tryDestructTile', () => {
|
||||||
|
it('should destruct a destructible tile', () => {
|
||||||
|
const tiles = new Array(100).fill(TileType.EMPTY);
|
||||||
|
tiles[0] = TileType.GRASS;
|
||||||
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
|
const result = tryDestructTile(world, 0, 0);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not destruct a non-destructible tile', () => {
|
||||||
|
const tiles = new Array(100).fill(TileType.EMPTY);
|
||||||
|
tiles[0] = TileType.WALL;
|
||||||
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
|
const result = tryDestructTile(world, 0, 0);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(world.tiles[0]).toBe(TileType.WALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for out of bounds', () => {
|
||||||
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
|
expect(tryDestructTile(world, -1, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPlayerOnExit', () => {
|
||||||
|
it('should return true when player is on exit', () => {
|
||||||
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
|
world.exit = { x: 5, y: 5 };
|
||||||
|
world.actors.set(1, {
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 5, y: 5 },
|
||||||
|
isPlayer: true
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(isPlayerOnExit(world, 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when player is not on exit', () => {
|
||||||
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
|
world.exit = { x: 5, y: 5 };
|
||||||
|
world.actors.set(1, {
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 4, y: 4 },
|
||||||
|
isPlayer: true
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(isPlayerOnExit(world, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when player does not exist', () => {
|
||||||
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
|
world.exit = { x: 5, y: 5 };
|
||||||
|
|
||||||
|
expect(isPlayerOnExit(world, 999)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +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, tryDestructTile } from "../world/world-logic";
|
import { isBlocked, inBounds, tryDestructTile } from "../world/world-logic";
|
||||||
import { isDestructibleByWalk } from "../../core/terrain";
|
import { isDestructibleByWalk, blocksSight } 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";
|
||||||
@@ -240,9 +240,11 @@ function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantAct
|
|||||||
const viewRadius = 8; // Enemy vision range
|
const viewRadius = 8; // Enemy vision range
|
||||||
let canSee = false;
|
let canSee = false;
|
||||||
|
|
||||||
|
|
||||||
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||||
if (!inBounds(w, x, y)) return false;
|
if (!inBounds(w, x, y)) return false;
|
||||||
return !isWall(w, x, y);
|
const idx = y * w.width + x;
|
||||||
|
return !blocksSight(w.tiles[idx]);
|
||||||
});
|
});
|
||||||
|
|
||||||
fov.compute(enemy.pos.x, enemy.pos.y, viewRadius, (x: number, y: number) => {
|
fov.compute(enemy.pos.x, enemy.pos.y, viewRadius, (x: number, y: number) => {
|
||||||
@@ -303,36 +305,41 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
|||||||
|
|
||||||
// State transitions
|
// State transitions
|
||||||
let justAlerted = false;
|
let justAlerted = false;
|
||||||
if (canSee && enemy.aiState === "wandering") {
|
if (canSee) {
|
||||||
// Spotted player! Transition to alerted state
|
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
||||||
enemy.aiState = "alerted";
|
// Spotted player (or re-spotted)! Transition to alerted state
|
||||||
enemy.alertedAt = Date.now();
|
enemy.aiState = "alerted";
|
||||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
enemy.alertedAt = Date.now();
|
||||||
justAlerted = true;
|
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||||
} else if (enemy.aiState === "alerted") {
|
justAlerted = true;
|
||||||
// Check if alert period is over (1 second = 1000ms)
|
} else if (enemy.aiState === "pursuing") {
|
||||||
const alertDuration = 1000;
|
// Keep pursuing, update last known
|
||||||
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
|
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||||
enemy.aiState = "pursuing";
|
|
||||||
}
|
}
|
||||||
} else if (enemy.aiState === "pursuing") {
|
} else {
|
||||||
if (canSee) {
|
// Cannot see player
|
||||||
// Update last known position
|
if (enemy.aiState === "alerted") {
|
||||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
// Check if alert period is over (1 second = 1000ms)
|
||||||
} else {
|
const alertDuration = 1000;
|
||||||
// Lost sight - check if we've reached last known position
|
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
|
||||||
if (enemy.lastKnownPlayerPos) {
|
enemy.aiState = "pursuing";
|
||||||
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
|
}
|
||||||
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
|
} else if (enemy.aiState === "pursuing") {
|
||||||
if (distToLastKnown <= 1) {
|
// Lost sight while pursuing -> switch to searching
|
||||||
// Reached last known position, return to wandering
|
enemy.aiState = "searching";
|
||||||
enemy.aiState = "wandering";
|
} else if (enemy.aiState === "searching") {
|
||||||
enemy.lastKnownPlayerPos = undefined;
|
// Check if reached last known position
|
||||||
|
if (enemy.lastKnownPlayerPos) {
|
||||||
|
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
|
||||||
|
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
|
||||||
|
if (distToLastKnown <= 1) {
|
||||||
|
// Reached last known position, return to wandering
|
||||||
|
enemy.aiState = "wandering";
|
||||||
|
enemy.lastKnownPlayerPos = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
enemy.aiState = "wandering";
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No last known position, return to wandering
|
|
||||||
enemy.aiState = "wandering";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +349,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (enemy.aiState === "alerted") {
|
if (enemy.aiState === "alerted") {
|
||||||
// During alert, stay still (or could do small movement)
|
// During alert, stay still
|
||||||
return { action: { type: "wait" }, justAlerted };
|
return { action: { type: "wait" }, justAlerted };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,19 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
};
|
};
|
||||||
|
|
||||||
placeEnemies(floor, rooms, actors, random);
|
placeEnemies(floor, rooms, actors, random);
|
||||||
|
|
||||||
|
// Place doors for dungeon levels (Uniform/Digger)
|
||||||
|
// Caves (Floors 10+) shouldn't have manufactured doors
|
||||||
|
if (floor <= 9) {
|
||||||
|
placeDoors(width, height, tiles, rooms, random);
|
||||||
|
}
|
||||||
|
|
||||||
decorate(width, height, tiles, random, exit);
|
decorate(width, height, tiles, random, exit);
|
||||||
|
|
||||||
|
// CRITICAL FIX: Ensure player start position is always clear!
|
||||||
|
// Otherwise spawning in Grass (which blocks vision) makes the player blind.
|
||||||
|
tiles[playerY * width + playerX] = TileType.EMPTY;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
world: { width, height, tiles, actors, exit },
|
world: { width, height, tiles, actors, exit },
|
||||||
playerId
|
playerId
|
||||||
@@ -242,6 +253,9 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
// Create grass patches where noise is above threshold
|
// Create grass patches where noise is above threshold
|
||||||
if (grassValue > 0.35) {
|
if (grassValue > 0.35) {
|
||||||
tiles[i] = TileType.GRASS;
|
tiles[i] = TileType.GRASS;
|
||||||
|
} else if (grassValue > 0.25) {
|
||||||
|
// Transition zone: Saplings around grass clumps
|
||||||
|
tiles[i] = TileType.GRASS_SAPLINGS;
|
||||||
} else {
|
} else {
|
||||||
// Floor decorations (moss/rocks): 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);
|
||||||
@@ -344,3 +358,40 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
|||||||
|
|
||||||
|
|
||||||
export const makeTestWorld = generateWorld;
|
export const makeTestWorld = generateWorld;
|
||||||
|
|
||||||
|
function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[], random: () => number): void {
|
||||||
|
const checkAndPlaceDoor = (x: number, y: number) => {
|
||||||
|
const i = idx({ width, height } as any, x, y);
|
||||||
|
if (tiles[i] === TileType.EMPTY) {
|
||||||
|
// Found a connection (floor tile on perimeter)
|
||||||
|
|
||||||
|
// 50% chance to place a door
|
||||||
|
if (random() < 0.5) {
|
||||||
|
// 90% chance for closed door, 10% for open
|
||||||
|
tiles[i] = random() < 0.9 ? TileType.DOOR_CLOSED : TileType.DOOR_OPEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
// Scan top and bottom walls
|
||||||
|
const topY = room.y - 1;
|
||||||
|
const bottomY = room.y + room.height;
|
||||||
|
|
||||||
|
// Scan horizontal perimeters (iterate x from left-1 to right+1 to cover corners too if needed,
|
||||||
|
// but usually doors are in the middle segments. Let's cover the full range adjacent to room.)
|
||||||
|
for (let x = room.x; x < room.x + room.width; x++) {
|
||||||
|
if (topY >= 0) checkAndPlaceDoor(x, topY);
|
||||||
|
if (bottomY < height) checkAndPlaceDoor(x, bottomY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan left and right walls
|
||||||
|
const leftX = room.x - 1;
|
||||||
|
const rightX = room.x + room.width;
|
||||||
|
|
||||||
|
for (let y = room.y; y < room.y + room.height; y++) {
|
||||||
|
if (leftX >= 0) checkAndPlaceDoor(leftX, y);
|
||||||
|
if (rightX < width) checkAndPlaceDoor(rightX, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FOV } from "rot-js";
|
import { FOV } from "rot-js";
|
||||||
import { type World, type EntityId } from "../core/types";
|
import { type World, type EntityId } from "../core/types";
|
||||||
import { idx, inBounds, isWall } from "../engine/world/world-logic";
|
import { idx, inBounds } from "../engine/world/world-logic";
|
||||||
|
import { blocksSight } from "../core/terrain";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ export class FovManager {
|
|||||||
|
|
||||||
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||||
if (!inBounds(world, x, y)) return false;
|
if (!inBounds(world, x, y)) return false;
|
||||||
return !isWall(world, x, y);
|
const idx = y * world.width + x;
|
||||||
|
return !blocksSight(world.tiles[idx]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user