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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user