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:
Денис Шкабатур
2026-02-12 12:47:21 +03:00
parent ddbca12740
commit bc472d0f77
12 changed files with 749 additions and 62 deletions

38
src/data/biomes.json Normal file
View 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]
}
}
]

View File

@@ -1,91 +1,83 @@
import Phaser from 'phaser';
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 { createGameEntity, removeGameEntity } from '../ecs/factory';
import { removeGameEntity } from '../ecs/factory';
import { PhaserBridge } from '../ecs/bridge';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
const ENTITY_COUNT = 20;
const COLORS = [
0x00ff88, 0xff0044, 0x44aaff, 0xffaa00,
0xff44ff, 0x44ffaa, 0xffff44, 0xaa44ff,
];
import biomeDataArray from '../data/biomes.json';
import type { BiomeData } from '../world/types';
import { generateWorld } from '../world/generator';
import { createWorldTilemap } from '../world/tilemap';
import { setupCamera, updateCamera, type CameraKeys } from '../world/camera';
import { Minimap } from '../world/minimap';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
private cameraKeys!: CameraKeys;
private minimap!: Minimap;
private statsText!: Phaser.GameObjects.Text;
private worldSeed!: number;
constructor() {
super({ key: 'GameScene' });
}
create(): void {
// 1. Initialize ECS
// 1. Initialize ECS (needed for future entity systems)
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
// 2. Spawn bouncing circles with random properties
for (let i = 0; i < ENTITY_COUNT; i++) {
const speed = 50 + Math.random() * 150;
const angle = Math.random() * Math.PI * 2;
// 2. Generate world
const biome = biomeDataArray[0] as BiomeData;
this.worldSeed = Date.now() % 1000000;
const worldData = generateWorld(biome, this.worldSeed);
createGameEntity(this.gameWorld.world, {
position: {
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. Create tilemap
createWorldTilemap(this, worldData);
// 3. UI labels
this.add.text(10, 10, 'Phase 2: ECS Foundation', {
fontSize: '14px',
// 4. Camera with bounds and WASD controls
const worldPixelW = biome.mapWidth * biome.tileSize;
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',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 4, y: 2 },
});
this.statsText = this.add.text(10, 30, '', {
fontSize: '12px',
color: '#557755',
fontFamily: 'monospace',
});
this.statsText.setScrollFactor(0);
this.statsText.setDepth(100);
}
update(_time: number, delta: number): void {
// 1. Update world time
updateTime(this.gameWorld, delta);
// 2. Run systems
movementSystem(this.gameWorld.world, delta);
bounceSystem(this.gameWorld.world, GAME_WIDTH, GAME_HEIGHT);
// 2. Camera movement
updateCamera(this, this.cameraKeys, delta);
// 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);
for (const eid of dead) {
removeGameEntity(this.gameWorld.world, eid);
}
// 4. Sync to Phaser
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;
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
View 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
View 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
View 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
View 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
View 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
View 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;
}