diff --git a/PROGRESS.md b/PROGRESS.md index 8e56e87..8ddbf8e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # Synthesis — Development Progress > **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] 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 -_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) -- [ ] 3.2 Biome data (`biomes.json` — Catalytic Wastes) -- [ ] 3.3 Noise generation (simplex-noise, seed-based) -- [ ] 3.4 Tile types (scorched earth, acid pools, crystal formations, geysers, mineral veins) -- [ ] 3.5 Resource placement (ores/minerals based on biome params + noise) -- [ ] 3.6 Camera (follow player, zoom, clamp to map bounds) -- [ ] 3.7 Minimap +- [ ] 4.1 Player entity + WASD controller +- [ ] 4.2 Inventory (weight-based, element stacking) +- [ ] 4.3 Element collection from world objects +- [ ] 4.4 Crafting (chemistry engine integration) +- [ ] 4.5 Projectile system (throw elements/compounds) +- [ ] 4.6 Quick slots (1-2-3-4 hotkeys) +- [ ] 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 | | 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 | +| 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) | diff --git a/package-lock.json b/package-lock.json index 9256be1..02e8961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "bitecs": "^0.4.0", - "phaser": "^3.80.0" + "phaser": "^3.80.0", + "simplex-noise": "^4.0.3" }, "devDependencies": { "happy-dom": "^20.6.1", @@ -1269,6 +1270,12 @@ "dev": true, "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 5e5eeb2..31e8269 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "bitecs": "^0.4.0", - "phaser": "^3.80.0" + "phaser": "^3.80.0", + "simplex-noise": "^4.0.3" }, "devDependencies": { "happy-dom": "^20.6.1", diff --git a/src/data/biomes.json b/src/data/biomes.json new file mode 100644 index 0000000..80db81c --- /dev/null +++ b/src/data/biomes.json @@ -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] + } + } +] diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 1bd5242..6ffaf6a 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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`, ); } } diff --git a/src/world/camera.ts b/src/world/camera.ts new file mode 100644 index 0000000..0c0baa4 --- /dev/null +++ b/src/world/camera.ts @@ -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; +} diff --git a/src/world/generator.ts b/src/world/generator.ts new file mode 100644 index 0000000..b337cb6 --- /dev/null +++ b/src/world/generator.ts @@ -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; +} diff --git a/src/world/minimap.ts b/src/world/minimap.ts new file mode 100644 index 0000000..4c65df6 --- /dev/null +++ b/src/world/minimap.ts @@ -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(); + } +} diff --git a/src/world/noise.ts b/src/world/noise.ts new file mode 100644 index 0000000..6529355 --- /dev/null +++ b/src/world/noise.ts @@ -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; +} diff --git a/src/world/tilemap.ts b/src/world/tilemap.ts new file mode 100644 index 0000000..8e64d39 --- /dev/null +++ b/src/world/tilemap.ts @@ -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)); +} diff --git a/src/world/types.ts b/src/world/types.ts new file mode 100644 index 0000000..e49a28c --- /dev/null +++ b/src/world/types.ts @@ -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; +} diff --git a/tests/world.test.ts b/tests/world.test.ts new file mode 100644 index 0000000..483f62b --- /dev/null +++ b/tests/world.test.ts @@ -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(); + 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(); + 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]); + } + } + }); +});