Phase 3: World generation — procedural tilemap for Catalytic Wastes
- simplex-noise with seeded PRNG (mulberry32) for deterministic generation - Biome data: 8 tile types (scorched earth, cracked ground, ash sand, acid pools, acid shallow, crystals, geysers, mineral veins) - Elevation noise → base terrain; detail noise → geyser/mineral overlays - Canvas-based tileset with per-pixel brightness variation - Phaser tilemap with collision on non-walkable tiles - Camera: WASD movement, mouse wheel zoom (0.5x–3x), world bounds - Minimap: 160x160 canvas overview with viewport indicator - 21 world generation tests passing (95 total) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
31
PROGRESS.md
31
PROGRESS.md
@@ -1,7 +1,7 @@
|
|||||||
# Synthesis — Development Progress
|
# Synthesis — Development Progress
|
||||||
|
|
||||||
> **Last updated:** 2026-02-12
|
> **Last updated:** 2026-02-12
|
||||||
> **Current phase:** Phase 2 ✅ → Ready for Phase 3
|
> **Current phase:** Phase 3 ✅ → Ready for Phase 4
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,23 +38,33 @@
|
|||||||
- [x] 2.7 Visual test — 20 colored circles bouncing at 60fps, GameScene (`src/scenes/GameScene.ts`)
|
- [x] 2.7 Visual test — 20 colored circles bouncing at 60fps, GameScene (`src/scenes/GameScene.ts`)
|
||||||
- [x] Unit tests — 39 passing (`tests/ecs.test.ts`)
|
- [x] Unit tests — 39 passing (`tests/ecs.test.ts`)
|
||||||
|
|
||||||
|
### Phase 3: World Generation ✅
|
||||||
|
- [x] 3.1 Tilemap system — canvas tileset with per-pixel variation + Phaser tilemap (`src/world/tilemap.ts`)
|
||||||
|
- [x] 3.2 Biome data — Catalytic Wastes: 8 tile types (`src/data/biomes.json`)
|
||||||
|
- [x] 3.3 Noise generation — simplex-noise, seeded PRNG (mulberry32), deterministic (`src/world/noise.ts`)
|
||||||
|
- [x] 3.4 World generator — elevation noise → base terrain, detail noise → overlays (`src/world/generator.ts`)
|
||||||
|
- [x] 3.5 Resource placement — geysers on acid-shallow zones, mineral veins on ground (`src/world/generator.ts`)
|
||||||
|
- [x] 3.6 Camera — WASD movement, mouse wheel zoom (0.5x–3x), bounds clamping (`src/world/camera.ts`)
|
||||||
|
- [x] 3.7 Minimap — canvas-based 160x160 overview, viewport indicator, border (`src/world/minimap.ts`)
|
||||||
|
- [x] Unit tests — 21 passing (`tests/world.test.ts`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
_None — ready to begin Phase 3_
|
_None — ready to begin Phase 4_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Up Next: Phase 3 — World Generation
|
## Up Next: Phase 4 — Player Systems
|
||||||
|
|
||||||
- [ ] 3.1 Tilemap system (Phaser Tilemap from data-driven tile definitions)
|
- [ ] 4.1 Player entity + WASD controller
|
||||||
- [ ] 3.2 Biome data (`biomes.json` — Catalytic Wastes)
|
- [ ] 4.2 Inventory (weight-based, element stacking)
|
||||||
- [ ] 3.3 Noise generation (simplex-noise, seed-based)
|
- [ ] 4.3 Element collection from world objects
|
||||||
- [ ] 3.4 Tile types (scorched earth, acid pools, crystal formations, geysers, mineral veins)
|
- [ ] 4.4 Crafting (chemistry engine integration)
|
||||||
- [ ] 3.5 Resource placement (ores/minerals based on biome params + noise)
|
- [ ] 4.5 Projectile system (throw elements/compounds)
|
||||||
- [ ] 3.6 Camera (follow player, zoom, clamp to map bounds)
|
- [ ] 4.6 Quick slots (1-2-3-4 hotkeys)
|
||||||
- [ ] 3.7 Minimap
|
- [ ] 4.7 HUD (UIScene: health ring, inventory bar, element info)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,3 +81,4 @@ None
|
|||||||
| 1 | 2026-02-12 | Phase 0 | Project setup: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, plan |
|
| 1 | 2026-02-12 | Phase 0 | Project setup: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, plan |
|
||||||
| 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing |
|
| 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing |
|
||||||
| 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing |
|
| 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing |
|
||||||
|
| 4 | 2026-02-12 | Phase 3 | World generation: simplex noise (seeded), 80x80 tilemap with 8 tile types, Catalytic Wastes biome, camera WASD+zoom, minimap with viewport indicator, 21 tests passing (95 total) |
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bitecs": "^0.4.0",
|
"bitecs": "^0.4.0",
|
||||||
"phaser": "^3.80.0"
|
"phaser": "^3.80.0",
|
||||||
|
"simplex-noise": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
@@ -1269,6 +1270,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simplex-noise": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bitecs": "^0.4.0",
|
"bitecs": "^0.4.0",
|
||||||
"phaser": "^3.80.0"
|
"phaser": "^3.80.0",
|
||||||
|
"simplex-noise": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
|
|||||||
38
src/data/biomes.json
Normal file
38
src/data/biomes.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "catalytic-wastes",
|
||||||
|
"name": "Catalytic Wastes",
|
||||||
|
"nameRu": "Каталитические Пустоши",
|
||||||
|
"description": "A blasted landscape of scorched earth, acid pools, and crystalline formations",
|
||||||
|
"descriptionRu": "Опалённый ландшафт из выжженной земли, кислотных озёр и кристаллических формаций",
|
||||||
|
"tileSize": 32,
|
||||||
|
"mapWidth": 80,
|
||||||
|
"mapHeight": 80,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 0, "name": "scorched-earth", "nameRu": "Выжженная земля", "color": "#2a1f0e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 1, "name": "cracked-ground", "nameRu": "Потрескавшаяся земля", "color": "#3d2b14", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 2, "name": "ash-sand", "nameRu": "Пепельный песок", "color": "#4a3d2e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 3, "name": "acid-pool", "nameRu": "Кислотное озеро", "color": "#1a6b0a", "walkable": false, "damage": 10, "interactive": false, "resource": false },
|
||||||
|
{ "id": 4, "name": "acid-shallow", "nameRu": "Кислотная отмель", "color": "#3a9420", "walkable": true, "damage": 3, "interactive": false, "resource": false },
|
||||||
|
{ "id": 5, "name": "crystal", "nameRu": "Кристаллическая формация", "color": "#7b5ea7", "walkable": false, "damage": 0, "interactive": false, "resource": false },
|
||||||
|
{ "id": 6, "name": "geyser", "nameRu": "Гейзер", "color": "#e85d10", "walkable": false, "damage": 0, "interactive": true, "resource": false },
|
||||||
|
{ "id": 7, "name": "mineral-vein", "nameRu": "Минеральная жила", "color": "#c0a030", "walkable": true, "damage": 0, "interactive": false, "resource": true }
|
||||||
|
],
|
||||||
|
"generation": {
|
||||||
|
"elevationScale": 0.06,
|
||||||
|
"detailScale": 0.15,
|
||||||
|
"elevationRules": [
|
||||||
|
{ "below": 0.22, "tileId": 3 },
|
||||||
|
{ "below": 0.30, "tileId": 4 },
|
||||||
|
{ "below": 0.52, "tileId": 0 },
|
||||||
|
{ "below": 0.70, "tileId": 1 },
|
||||||
|
{ "below": 0.84, "tileId": 2 },
|
||||||
|
{ "below": 1.00, "tileId": 5 }
|
||||||
|
],
|
||||||
|
"geyserThreshold": 0.93,
|
||||||
|
"mineralThreshold": 0.90,
|
||||||
|
"geyserOnTile": 4,
|
||||||
|
"mineralOnTiles": [0, 1, 2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,91 +1,83 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
||||||
import { movementSystem, bounceSystem } from '../ecs/systems/movement';
|
import { movementSystem } from '../ecs/systems/movement';
|
||||||
import { healthSystem } from '../ecs/systems/health';
|
import { healthSystem } from '../ecs/systems/health';
|
||||||
import { createGameEntity, removeGameEntity } from '../ecs/factory';
|
import { removeGameEntity } from '../ecs/factory';
|
||||||
import { PhaserBridge } from '../ecs/bridge';
|
import { PhaserBridge } from '../ecs/bridge';
|
||||||
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
import biomeDataArray from '../data/biomes.json';
|
||||||
|
import type { BiomeData } from '../world/types';
|
||||||
const ENTITY_COUNT = 20;
|
import { generateWorld } from '../world/generator';
|
||||||
const COLORS = [
|
import { createWorldTilemap } from '../world/tilemap';
|
||||||
0x00ff88, 0xff0044, 0x44aaff, 0xffaa00,
|
import { setupCamera, updateCamera, type CameraKeys } from '../world/camera';
|
||||||
0xff44ff, 0x44ffaa, 0xffff44, 0xaa44ff,
|
import { Minimap } from '../world/minimap';
|
||||||
];
|
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private gameWorld!: GameWorld;
|
private gameWorld!: GameWorld;
|
||||||
private bridge!: PhaserBridge;
|
private bridge!: PhaserBridge;
|
||||||
|
private cameraKeys!: CameraKeys;
|
||||||
|
private minimap!: Minimap;
|
||||||
private statsText!: Phaser.GameObjects.Text;
|
private statsText!: Phaser.GameObjects.Text;
|
||||||
|
private worldSeed!: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'GameScene' });
|
super({ key: 'GameScene' });
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
// 1. Initialize ECS
|
// 1. Initialize ECS (needed for future entity systems)
|
||||||
this.gameWorld = createGameWorld();
|
this.gameWorld = createGameWorld();
|
||||||
this.bridge = new PhaserBridge(this);
|
this.bridge = new PhaserBridge(this);
|
||||||
|
|
||||||
// 2. Spawn bouncing circles with random properties
|
// 2. Generate world
|
||||||
for (let i = 0; i < ENTITY_COUNT; i++) {
|
const biome = biomeDataArray[0] as BiomeData;
|
||||||
const speed = 50 + Math.random() * 150;
|
this.worldSeed = Date.now() % 1000000;
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const worldData = generateWorld(biome, this.worldSeed);
|
||||||
|
|
||||||
createGameEntity(this.gameWorld.world, {
|
// 3. Create tilemap
|
||||||
position: {
|
createWorldTilemap(this, worldData);
|
||||||
x: 100 + Math.random() * (GAME_WIDTH - 200),
|
|
||||||
y: 100 + Math.random() * (GAME_HEIGHT - 200),
|
|
||||||
},
|
|
||||||
velocity: {
|
|
||||||
vx: Math.cos(angle) * speed,
|
|
||||||
vy: Math.sin(angle) * speed,
|
|
||||||
},
|
|
||||||
sprite: {
|
|
||||||
color: COLORS[i % COLORS.length],
|
|
||||||
radius: 6 + Math.random() * 14,
|
|
||||||
},
|
|
||||||
health: {
|
|
||||||
current: 100,
|
|
||||||
max: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. UI labels
|
// 4. Camera with bounds and WASD controls
|
||||||
this.add.text(10, 10, 'Phase 2: ECS Foundation', {
|
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||||
fontSize: '14px',
|
const worldPixelH = biome.mapHeight * biome.tileSize;
|
||||||
|
this.cameraKeys = setupCamera(this, worldPixelW, worldPixelH);
|
||||||
|
|
||||||
|
// 5. Minimap
|
||||||
|
this.minimap = new Minimap(this, worldData);
|
||||||
|
|
||||||
|
// 6. UI overlay
|
||||||
|
this.statsText = this.add.text(10, 10, '', {
|
||||||
|
fontSize: '12px',
|
||||||
color: '#00ff88',
|
color: '#00ff88',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#000000aa',
|
||||||
|
padding: { x: 4, y: 2 },
|
||||||
});
|
});
|
||||||
|
this.statsText.setScrollFactor(0);
|
||||||
this.statsText = this.add.text(10, 30, '', {
|
this.statsText.setDepth(100);
|
||||||
fontSize: '12px',
|
|
||||||
color: '#557755',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(_time: number, delta: number): void {
|
update(_time: number, delta: number): void {
|
||||||
// 1. Update world time
|
// 1. Update world time
|
||||||
updateTime(this.gameWorld, delta);
|
updateTime(this.gameWorld, delta);
|
||||||
|
|
||||||
// 2. Run systems
|
// 2. Camera movement
|
||||||
movementSystem(this.gameWorld.world, delta);
|
updateCamera(this, this.cameraKeys, delta);
|
||||||
bounceSystem(this.gameWorld.world, GAME_WIDTH, GAME_HEIGHT);
|
|
||||||
|
|
||||||
// 3. Health check + cleanup
|
// 3. ECS systems (no entities yet — future phases will add player, creatures)
|
||||||
|
movementSystem(this.gameWorld.world, delta);
|
||||||
const dead = healthSystem(this.gameWorld.world);
|
const dead = healthSystem(this.gameWorld.world);
|
||||||
for (const eid of dead) {
|
for (const eid of dead) {
|
||||||
removeGameEntity(this.gameWorld.world, eid);
|
removeGameEntity(this.gameWorld.world, eid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Sync to Phaser
|
|
||||||
this.bridge.sync(this.gameWorld.world);
|
this.bridge.sync(this.gameWorld.world);
|
||||||
|
|
||||||
// 5. Update stats
|
// 4. Minimap viewport
|
||||||
|
this.minimap.update(this.cameras.main);
|
||||||
|
|
||||||
|
// 5. Stats
|
||||||
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
||||||
this.statsText.setText(
|
this.statsText.setText(
|
||||||
`${this.bridge.entityCount} entities | tick ${this.gameWorld.time.tick} | ${fps} fps`,
|
`seed: ${this.worldSeed} | ${fps} fps | WASD move, scroll zoom`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/world/camera.ts
Normal file
69
src/world/camera.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
|
||||||
|
/** Keyboard keys for camera movement */
|
||||||
|
export interface CameraKeys {
|
||||||
|
up: Phaser.Input.Keyboard.Key;
|
||||||
|
down: Phaser.Input.Keyboard.Key;
|
||||||
|
left: Phaser.Input.Keyboard.Key;
|
||||||
|
right: Phaser.Input.Keyboard.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up camera with bounds, zoom, and WASD movement
|
||||||
|
* (Temporary controls — replaced by player follow in Phase 4)
|
||||||
|
*/
|
||||||
|
export function setupCamera(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldPixelWidth: number,
|
||||||
|
worldPixelHeight: number,
|
||||||
|
): CameraKeys {
|
||||||
|
const camera = scene.cameras.main;
|
||||||
|
camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight);
|
||||||
|
camera.setZoom(1);
|
||||||
|
|
||||||
|
// Start centered
|
||||||
|
camera.scrollX = (worldPixelWidth - camera.width) / 2;
|
||||||
|
camera.scrollY = (worldPixelHeight - camera.height) / 2;
|
||||||
|
|
||||||
|
// WASD keys
|
||||||
|
const keyboard = scene.input.keyboard;
|
||||||
|
if (!keyboard) {
|
||||||
|
throw new Error('Keyboard plugin not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys: CameraKeys = {
|
||||||
|
up: keyboard.addKey('W'),
|
||||||
|
down: keyboard.addKey('S'),
|
||||||
|
left: keyboard.addKey('A'),
|
||||||
|
right: keyboard.addKey('D'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse wheel zoom (0.5x – 3x)
|
||||||
|
scene.input.on('wheel', (
|
||||||
|
_pointer: unknown,
|
||||||
|
_gameObjects: unknown,
|
||||||
|
_deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
) => {
|
||||||
|
const newZoom = Phaser.Math.Clamp(camera.zoom - deltaY * 0.001, 0.5, 3);
|
||||||
|
camera.setZoom(newZoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update camera position based on WASD keys — call each frame */
|
||||||
|
export function updateCamera(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
keys: CameraKeys,
|
||||||
|
deltaMs: number,
|
||||||
|
): void {
|
||||||
|
const camera = scene.cameras.main;
|
||||||
|
const speed = 300 / camera.zoom; // faster when zoomed out
|
||||||
|
const dt = deltaMs / 1000;
|
||||||
|
|
||||||
|
if (keys.left.isDown) camera.scrollX -= speed * dt;
|
||||||
|
if (keys.right.isDown) camera.scrollX += speed * dt;
|
||||||
|
if (keys.up.isDown) camera.scrollY -= speed * dt;
|
||||||
|
if (keys.down.isDown) camera.scrollY += speed * dt;
|
||||||
|
}
|
||||||
67
src/world/generator.ts
Normal file
67
src/world/generator.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { BiomeData, TileGrid, WorldData } from './types';
|
||||||
|
import { createSeededNoise, sampleNoise, type Noise2D } from './noise';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a world grid from biome data and a seed
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Elevation noise → base terrain type (acid pools low, crystals high)
|
||||||
|
* 2. Detail noise → sparse overlay (geysers near acid, minerals on ground)
|
||||||
|
* 3. Each tile deterministically chosen from noise values
|
||||||
|
*/
|
||||||
|
export function generateWorld(biome: BiomeData, seed: number): WorldData {
|
||||||
|
const elevationNoise = createSeededNoise(seed);
|
||||||
|
const detailNoise = createSeededNoise(seed + 7919); // prime offset for independence
|
||||||
|
|
||||||
|
const grid: TileGrid = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < biome.mapHeight; y++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let x = 0; x < biome.mapWidth; x++) {
|
||||||
|
const elevation = sampleNoise(elevationNoise, x, y, biome.generation.elevationScale);
|
||||||
|
const detail = sampleNoise(detailNoise, x, y, biome.generation.detailScale);
|
||||||
|
row.push(determineTile(elevation, detail, biome));
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { grid, biome, seed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine tile type from noise values
|
||||||
|
*
|
||||||
|
* Base tile from elevation thresholds, then overlay specials:
|
||||||
|
* - Geysers spawn on acid-shallow tiles with very high detail noise
|
||||||
|
* - Mineral veins spawn on walkable ground with high detail noise
|
||||||
|
*/
|
||||||
|
function determineTile(elevation: number, detail: number, biome: BiomeData): number {
|
||||||
|
const gen = biome.generation;
|
||||||
|
|
||||||
|
// Base tile from elevation rules (first matching threshold)
|
||||||
|
let baseTileId = gen.elevationRules[gen.elevationRules.length - 1].tileId;
|
||||||
|
for (const rule of gen.elevationRules) {
|
||||||
|
if (elevation < rule.below) {
|
||||||
|
baseTileId = rule.tileId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geyser overlay: on acid-shallow + very high detail noise
|
||||||
|
if (baseTileId === gen.geyserOnTile && detail > gen.geyserThreshold) {
|
||||||
|
return findTileIdByName(biome, 'geyser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mineral overlay: on walkable ground + high detail noise
|
||||||
|
if (gen.mineralOnTiles.includes(baseTileId) && detail > gen.mineralThreshold) {
|
||||||
|
return findTileIdByName(biome, 'mineral-vein');
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseTileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find tile ID by name, falling back to 0 if not found */
|
||||||
|
function findTileIdByName(biome: BiomeData, name: string): number {
|
||||||
|
const tile = biome.tiles.find(t => t.name === name);
|
||||||
|
return tile ? tile.id : 0;
|
||||||
|
}
|
||||||
126
src/world/minimap.ts
Normal file
126
src/world/minimap.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { TileData, TileGrid, WorldData } from './types';
|
||||||
|
|
||||||
|
const MINIMAP_DEPTH = 100;
|
||||||
|
const VIEWPORT_DEPTH = 101;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimap — small overview of the entire world in the top-right corner
|
||||||
|
*
|
||||||
|
* Shows tile colors at reduced scale with a white rectangle
|
||||||
|
* indicating the camera's current viewport
|
||||||
|
*/
|
||||||
|
export class Minimap {
|
||||||
|
private image: Phaser.GameObjects.Image;
|
||||||
|
private viewport: Phaser.GameObjects.Graphics;
|
||||||
|
private border: Phaser.GameObjects.Graphics;
|
||||||
|
private mapWidth: number;
|
||||||
|
private mapHeight: number;
|
||||||
|
private tileSize: number;
|
||||||
|
private minimapScale: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldData: WorldData,
|
||||||
|
scale: number = 2,
|
||||||
|
) {
|
||||||
|
const { grid, biome } = worldData;
|
||||||
|
this.mapWidth = biome.mapWidth;
|
||||||
|
this.mapHeight = biome.mapHeight;
|
||||||
|
this.tileSize = biome.tileSize;
|
||||||
|
this.minimapScale = scale;
|
||||||
|
|
||||||
|
const minimapW = this.mapWidth * scale;
|
||||||
|
const minimapH = this.mapHeight * scale;
|
||||||
|
|
||||||
|
// Generate minimap texture
|
||||||
|
this.createMinimapTexture(scene, grid, biome.tiles, scale);
|
||||||
|
|
||||||
|
// Position in top-right corner
|
||||||
|
const screenW = scene.cameras.main.width;
|
||||||
|
const padding = 10;
|
||||||
|
const right = screenW - padding;
|
||||||
|
const top = padding;
|
||||||
|
|
||||||
|
// Border
|
||||||
|
this.border = scene.add.graphics();
|
||||||
|
this.border.setScrollFactor(0);
|
||||||
|
this.border.setDepth(MINIMAP_DEPTH);
|
||||||
|
this.border.lineStyle(2, 0x00ff88, 0.6);
|
||||||
|
this.border.strokeRect(
|
||||||
|
right - minimapW - 1,
|
||||||
|
top - 1,
|
||||||
|
minimapW + 2,
|
||||||
|
minimapH + 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Semi-transparent background
|
||||||
|
this.border.fillStyle(0x000000, 0.4);
|
||||||
|
this.border.fillRect(right - minimapW, top, minimapW, minimapH);
|
||||||
|
|
||||||
|
// Minimap image
|
||||||
|
this.image = scene.add.image(right, top, 'minimap');
|
||||||
|
this.image.setOrigin(1, 0);
|
||||||
|
this.image.setScrollFactor(0);
|
||||||
|
this.image.setDepth(MINIMAP_DEPTH);
|
||||||
|
|
||||||
|
// Viewport indicator
|
||||||
|
this.viewport = scene.add.graphics();
|
||||||
|
this.viewport.setScrollFactor(0);
|
||||||
|
this.viewport.setDepth(VIEWPORT_DEPTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create canvas texture for minimap (1 pixel per tile * scale) */
|
||||||
|
private createMinimapTexture(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
grid: TileGrid,
|
||||||
|
tiles: TileData[],
|
||||||
|
scale: number,
|
||||||
|
): void {
|
||||||
|
const h = grid.length;
|
||||||
|
const w = grid[0].length;
|
||||||
|
const canvasW = w * scale;
|
||||||
|
const canvasH = h * scale;
|
||||||
|
|
||||||
|
const canvasTexture = scene.textures.createCanvas('minimap', canvasW, canvasH);
|
||||||
|
const ctx = canvasTexture.getContext();
|
||||||
|
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const tileId = grid[y][x];
|
||||||
|
ctx.fillStyle = tiles[tileId].color;
|
||||||
|
ctx.fillRect(x * scale, y * scale, scale, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasTexture.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the viewport indicator rectangle — call each frame */
|
||||||
|
update(camera: Phaser.Cameras.Scene2D.Camera): void {
|
||||||
|
const screenW = camera.width;
|
||||||
|
const padding = 10;
|
||||||
|
const minimapW = this.mapWidth * this.minimapScale;
|
||||||
|
const minimapX = screenW - padding - minimapW;
|
||||||
|
const minimapY = padding;
|
||||||
|
|
||||||
|
// Camera's visible area in world coordinates
|
||||||
|
const worldView = camera.worldView;
|
||||||
|
|
||||||
|
// Convert world coordinates to minimap coordinates
|
||||||
|
const viewX = minimapX + (worldView.x / this.tileSize) * this.minimapScale;
|
||||||
|
const viewY = minimapY + (worldView.y / this.tileSize) * this.minimapScale;
|
||||||
|
const viewW = (worldView.width / this.tileSize) * this.minimapScale;
|
||||||
|
const viewH = (worldView.height / this.tileSize) * this.minimapScale;
|
||||||
|
|
||||||
|
this.viewport.clear();
|
||||||
|
this.viewport.lineStyle(1, 0xffffff, 0.8);
|
||||||
|
this.viewport.strokeRect(viewX, viewY, viewW, viewH);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.image.destroy();
|
||||||
|
this.viewport.destroy();
|
||||||
|
this.border.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/world/noise.ts
Normal file
39
src/world/noise.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createNoise2D } from 'simplex-noise';
|
||||||
|
|
||||||
|
/** 2D noise function returning values in [-1, 1] */
|
||||||
|
export type Noise2D = (x: number, y: number) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mulberry32 — fast, seedable 32-bit PRNG
|
||||||
|
* Returns values in [0, 1)
|
||||||
|
*/
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let state = seed | 0;
|
||||||
|
return () => {
|
||||||
|
state = (state + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a seeded 2D simplex noise function */
|
||||||
|
export function createSeededNoise(seed: number): Noise2D {
|
||||||
|
return createNoise2D(mulberry32(seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample 2D noise normalized to [0, 1]
|
||||||
|
* @param noise - noise function (returns [-1, 1])
|
||||||
|
* @param x - world x coordinate
|
||||||
|
* @param y - world y coordinate
|
||||||
|
* @param scale - frequency (higher = more detail, smaller features)
|
||||||
|
*/
|
||||||
|
export function sampleNoise(
|
||||||
|
noise: Noise2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scale: number,
|
||||||
|
): number {
|
||||||
|
return (noise(x * scale, y * scale) + 1) / 2;
|
||||||
|
}
|
||||||
102
src/world/tilemap.ts
Normal file
102
src/world/tilemap.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { TileData, WorldData } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Phaser tilemap from generated world data
|
||||||
|
*
|
||||||
|
* 1. Generates a canvas-based tileset texture (colored squares with per-pixel variation)
|
||||||
|
* 2. Creates a Phaser Tilemap from the grid data
|
||||||
|
* 3. Sets collision for non-walkable tiles
|
||||||
|
*/
|
||||||
|
export function createWorldTilemap(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
worldData: WorldData,
|
||||||
|
): Phaser.Tilemaps.Tilemap {
|
||||||
|
const { grid, biome } = worldData;
|
||||||
|
const textureKey = `tileset-${biome.id}`;
|
||||||
|
|
||||||
|
// 1. Generate tileset texture
|
||||||
|
createTilesetTexture(scene, biome.tiles, biome.tileSize, textureKey);
|
||||||
|
|
||||||
|
// 2. Create tilemap from grid
|
||||||
|
const map = scene.make.tilemap({
|
||||||
|
data: grid,
|
||||||
|
tileWidth: biome.tileSize,
|
||||||
|
tileHeight: biome.tileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileset = map.addTilesetImage(textureKey, textureKey, biome.tileSize, biome.tileSize, 0, 0);
|
||||||
|
if (!tileset) {
|
||||||
|
throw new Error(`Failed to create tileset: ${textureKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = map.createLayer(0, tileset, 0, 0);
|
||||||
|
if (!layer) {
|
||||||
|
throw new Error('Failed to create tilemap layer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Set collision for non-walkable tiles
|
||||||
|
const nonWalkableIds = biome.tiles.filter(t => !t.walkable).map(t => t.id);
|
||||||
|
layer.setCollision(nonWalkableIds);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a canvas tileset texture with per-pixel brightness variation
|
||||||
|
* Creates visual micro-texture so flat-colored tiles look less monotonous
|
||||||
|
*/
|
||||||
|
function createTilesetTexture(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
tiles: TileData[],
|
||||||
|
tileSize: number,
|
||||||
|
textureKey: string,
|
||||||
|
): void {
|
||||||
|
const width = tiles.length * tileSize;
|
||||||
|
const height = tileSize;
|
||||||
|
|
||||||
|
const canvasTexture = scene.textures.createCanvas(textureKey, width, height);
|
||||||
|
const ctx = canvasTexture.getContext();
|
||||||
|
const imageData = ctx.createImageData(width, height);
|
||||||
|
const pixels = imageData.data;
|
||||||
|
|
||||||
|
for (const tile of tiles) {
|
||||||
|
const [baseR, baseG, baseB] = hexToRgb(tile.color);
|
||||||
|
|
||||||
|
for (let py = 0; py < tileSize; py++) {
|
||||||
|
for (let px = 0; px < tileSize; px++) {
|
||||||
|
// Per-pixel brightness variation (±12) for texture
|
||||||
|
const hash = pixelHash(tile.id, px, py);
|
||||||
|
const variation = (hash % 25) - 12;
|
||||||
|
|
||||||
|
const idx = ((py * width) + (tile.id * tileSize + px)) * 4;
|
||||||
|
pixels[idx] = clamp(baseR + variation, 0, 255);
|
||||||
|
pixels[idx + 1] = clamp(baseG + variation, 0, 255);
|
||||||
|
pixels[idx + 2] = clamp(baseB + variation, 0, 255);
|
||||||
|
pixels[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
canvasTexture.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert hex color string (#RRGGBB) to [R, G, B] */
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic hash for per-pixel variation */
|
||||||
|
function pixelHash(tileId: number, x: number, y: number): number {
|
||||||
|
let n = tileId * 73856093 + x * 19349663 + y * 83492791;
|
||||||
|
n = ((n >> 16) ^ n) * 0x45d9f3b;
|
||||||
|
n = ((n >> 16) ^ n) * 0x45d9f3b;
|
||||||
|
return ((n >> 16) ^ n) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp value to [min, max] */
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
52
src/world/types.ts
Normal file
52
src/world/types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/** Single tile type definition */
|
||||||
|
export interface TileData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
color: string;
|
||||||
|
walkable: boolean;
|
||||||
|
damage: number; // 0 = no damage
|
||||||
|
interactive: boolean; // true = can interact (e.g. geyser)
|
||||||
|
resource: boolean; // true = harvestable resource
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elevation → tile mapping rule (sorted by "below" ascending) */
|
||||||
|
export interface ElevationRule {
|
||||||
|
below: number;
|
||||||
|
tileId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Biome generation parameters */
|
||||||
|
export interface BiomeGeneration {
|
||||||
|
elevationScale: number;
|
||||||
|
detailScale: number;
|
||||||
|
elevationRules: ElevationRule[];
|
||||||
|
geyserThreshold: number;
|
||||||
|
mineralThreshold: number;
|
||||||
|
geyserOnTile: number; // base tile ID where geysers can spawn
|
||||||
|
mineralOnTiles: number[]; // base tile IDs where minerals can spawn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete biome definition (loaded from biomes.json) */
|
||||||
|
export interface BiomeData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameRu: string;
|
||||||
|
description: string;
|
||||||
|
descriptionRu: string;
|
||||||
|
tileSize: number;
|
||||||
|
mapWidth: number;
|
||||||
|
mapHeight: number;
|
||||||
|
tiles: TileData[];
|
||||||
|
generation: BiomeGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2D grid of tile IDs — row-major [y][x] */
|
||||||
|
export type TileGrid = number[][];
|
||||||
|
|
||||||
|
/** Complete generated world data */
|
||||||
|
export interface WorldData {
|
||||||
|
grid: TileGrid;
|
||||||
|
biome: BiomeData;
|
||||||
|
seed: number;
|
||||||
|
}
|
||||||
183
tests/world.test.ts
Normal file
183
tests/world.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import biomeDataArray from '../src/data/biomes.json';
|
||||||
|
import { createSeededNoise, sampleNoise } from '../src/world/noise';
|
||||||
|
import { generateWorld } from '../src/world/generator';
|
||||||
|
import type { BiomeData } from '../src/world/types';
|
||||||
|
|
||||||
|
// Load the first biome — structural compatibility with BiomeData
|
||||||
|
const biome = biomeDataArray[0] as BiomeData;
|
||||||
|
|
||||||
|
// ─── Noise ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Seeded Noise', () => {
|
||||||
|
it('is deterministic with same seed', () => {
|
||||||
|
const n1 = createSeededNoise(42);
|
||||||
|
const n2 = createSeededNoise(42);
|
||||||
|
expect(n1(0.5, 0.5)).toBe(n2(0.5, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different results with different seeds', () => {
|
||||||
|
const n1 = createSeededNoise(42);
|
||||||
|
const n2 = createSeededNoise(99);
|
||||||
|
expect(n1(0.5, 0.5)).not.toBe(n2(0.5, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes to [0, 1] via sampleNoise', () => {
|
||||||
|
const noise = createSeededNoise(42);
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const val = sampleNoise(noise, i * 0.3, i * 0.7, 0.1);
|
||||||
|
expect(val).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(val).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varies across coordinates', () => {
|
||||||
|
const noise = createSeededNoise(42);
|
||||||
|
const values = new Set<number>();
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
values.add(sampleNoise(noise, i, 0, 0.1));
|
||||||
|
}
|
||||||
|
// Should have significant variety (not all same value)
|
||||||
|
expect(values.size).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Biome Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Biome Data', () => {
|
||||||
|
it('has valid structure', () => {
|
||||||
|
expect(biome.id).toBe('catalytic-wastes');
|
||||||
|
expect(biome.tileSize).toBe(32);
|
||||||
|
expect(biome.mapWidth).toBe(80);
|
||||||
|
expect(biome.mapHeight).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 8 tile types', () => {
|
||||||
|
expect(biome.tiles).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tile IDs are sequential starting from 0', () => {
|
||||||
|
biome.tiles.forEach((tile, index) => {
|
||||||
|
expect(tile.id).toBe(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has both walkable and non-walkable tiles', () => {
|
||||||
|
const walkable = biome.tiles.filter(t => t.walkable);
|
||||||
|
const blocked = biome.tiles.filter(t => !t.walkable);
|
||||||
|
expect(walkable.length).toBeGreaterThan(0);
|
||||||
|
expect(blocked.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('elevation rules cover full [0, 1] range', () => {
|
||||||
|
const rules = biome.generation.elevationRules;
|
||||||
|
expect(rules.length).toBeGreaterThan(0);
|
||||||
|
// Last rule should cover up to 1.0
|
||||||
|
expect(rules[rules.length - 1].below).toBe(1);
|
||||||
|
// Rules should be sorted ascending
|
||||||
|
for (let i = 1; i < rules.length; i++) {
|
||||||
|
expect(rules[i].below).toBeGreaterThan(rules[i - 1].below);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all elevation rule tileIds reference valid tiles', () => {
|
||||||
|
const validIds = new Set(biome.tiles.map(t => t.id));
|
||||||
|
for (const rule of biome.generation.elevationRules) {
|
||||||
|
expect(validIds.has(rule.tileId)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── World Generation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('World Generation', () => {
|
||||||
|
it('generates grid with correct dimensions', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
expect(world.grid).toHaveLength(biome.mapHeight);
|
||||||
|
for (const row of world.grid) {
|
||||||
|
expect(row).toHaveLength(biome.mapWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all tile IDs in grid are valid', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const validIds = new Set(biome.tiles.map(t => t.id));
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const tileId of row) {
|
||||||
|
expect(validIds.has(tileId)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic — same seed same map', () => {
|
||||||
|
const w1 = generateWorld(biome, 42);
|
||||||
|
const w2 = generateWorld(biome, 42);
|
||||||
|
expect(w1.grid).toEqual(w2.grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different seeds produce different maps', () => {
|
||||||
|
const w1 = generateWorld(biome, 42);
|
||||||
|
const w2 = generateWorld(biome, 99);
|
||||||
|
expect(w1.grid).not.toEqual(w2.grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores seed and biome reference', () => {
|
||||||
|
const world = generateWorld(biome, 12345);
|
||||||
|
expect(world.seed).toBe(12345);
|
||||||
|
expect(world.biome).toBe(biome);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has diverse tile distribution (no single tile > 60%)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const row of world.grid) {
|
||||||
|
for (const tileId of row) {
|
||||||
|
counts.set(tileId, (counts.get(tileId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = biome.mapWidth * biome.mapHeight;
|
||||||
|
// At least 4 different tile types present
|
||||||
|
expect(counts.size).toBeGreaterThanOrEqual(4);
|
||||||
|
// No single type dominates
|
||||||
|
for (const count of counts.values()) {
|
||||||
|
expect(count / total).toBeLessThan(0.6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates acid pools (low elevation)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const acidId = biome.tiles.find(t => t.name === 'acid-pool')?.id;
|
||||||
|
const hasAcid = world.grid.some(row => row.includes(acidId!));
|
||||||
|
expect(hasAcid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates crystal formations (high elevation)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const crystalId = biome.tiles.find(t => t.name === 'crystal')?.id;
|
||||||
|
const hasCrystals = world.grid.some(row => row.includes(crystalId!));
|
||||||
|
expect(hasCrystals).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates mineral veins (overlay on ground)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const mineralId = biome.tiles.find(t => t.name === 'mineral-vein')?.id;
|
||||||
|
const hasMinerals = world.grid.some(row => row.includes(mineralId!));
|
||||||
|
expect(hasMinerals).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates geysers (overlay near acid)', () => {
|
||||||
|
const world = generateWorld(biome, 42);
|
||||||
|
const geyserId = biome.tiles.find(t => t.name === 'geyser')?.id;
|
||||||
|
const hasGeysers = world.grid.some(row => row.includes(geyserId!));
|
||||||
|
expect(hasGeysers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces unique map every seed (sample 5 seeds)', () => {
|
||||||
|
const grids = [1, 2, 3, 4, 5].map(s => generateWorld(biome, s).grid);
|
||||||
|
for (let i = 0; i < grids.length; i++) {
|
||||||
|
for (let j = i + 1; j < grids.length; j++) {
|
||||||
|
expect(grids[i]).not.toEqual(grids[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user