From 4a2819695bb69cbe0f14b2b99c78a9e9e684f58a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 22:45:09 +0000 Subject: [PATCH] feat: procedural tilemap with river, ponds, dirt, and trees Replace colored-rectangle world rendering with a proper tilemap system using a 16x16 RPG tileset scaled 3x. Server generates terrain via seeded procedural generation (river, ponds, dirt patches, tree decorations) and sends it to clients. Client renders 3-layer Phaser tilemap with 8-neighbor autotiling for water/dirt edges. Co-Authored-By: Claude Opus 4.6 --- client/public/assets/tileset_16x16.png | Bin 0 -> 12112 bytes client/src/scenes/BootScene.ts | 4 + client/src/scenes/GameScene.ts | 119 +++++++++++++++--- server/src/game/GameLoop.ts | 23 ++-- server/src/map/GameMap.ts | 8 ++ server/src/map/mapGenerator.ts | 166 +++++++++++++++++++++++++ server/src/network/stateSerializer.ts | 2 + shared/src/constants.ts | 13 ++ shared/src/types.ts | 2 + 9 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 client/public/assets/tileset_16x16.png create mode 100644 server/src/map/mapGenerator.ts diff --git a/client/public/assets/tileset_16x16.png b/client/public/assets/tileset_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..13ccc5ed2571b33e0133803498c977c84a7e7495 GIT binary patch literal 12112 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 Q+RFhND!R}rh)wwa0Aae?k^lez literal 0 HcmV?d00001 diff --git a/client/src/scenes/BootScene.ts b/client/src/scenes/BootScene.ts index b757470..76f7aa9 100644 --- a/client/src/scenes/BootScene.ts +++ b/client/src/scenes/BootScene.ts @@ -9,6 +9,10 @@ export class BootScene extends Phaser.Scene { super({ key: 'BootScene' }); } + preload(): void { + this.load.image('tileset_16x16', 'assets/tileset_16x16.png'); + } + create(): void { const statusText = this.add.text( this.cameras.main.centerX, diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 3845cde..6ea4d6d 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -6,7 +6,7 @@ import { NpcInfoPanel } from '../ui/NpcInfoPanel.js'; import { CommandPanel } from '../ui/CommandPanel.js'; import { InteractionEmojiManager } from '../ui/InteractionEmoji.js'; import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared'; -import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction } from '@dflike/shared'; +import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_COLS, TILESET_SCALE } from '@dflike/shared'; interface EntitySprite { sprite: Phaser.GameObjects.Sprite; @@ -143,28 +143,115 @@ export class GameScene extends Phaser.Scene { } private drawWorld(): void { - const { worldWidth, worldHeight, obstacles, pointsOfInterest } = this.worldState; + const { worldWidth, worldHeight, terrain, decorations, pointsOfInterest } = this.worldState; - // Ground tiles - const graphics = this.add.graphics(); - for (let x = 0; x < worldWidth; x++) { - for (let y = 0; y < worldHeight; y++) { - const shade = ((x + y) % 2 === 0) ? 0x2d5a27 : 0x326b2e; - graphics.fillStyle(shade, 1); - graphics.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); + // --- Tile index mapping --- + // Tileset: 17 cols x 19 rows of 16x16 tiles (272x304px) + // Autotile blocks are 5 cols x 3 rows on the left side: + // Grass: rows 0-2 Water: rows 3-5 Dirt: rows 6-8 + // Layout per block: + // [NW-outer] [N-edge ] [NE-outer] [inner-NW] [inner-NE] + // [W-edge ] [center ] [E-edge ] [inner-SW] [inner-SE] + // [SW-outer] [S-edge ] [SE-outer] [variant ] [variant ] + + const C = TILESET_COLS; // 17 + + interface AutotileSet { + nw: number; n: number; ne: number; + w: number; c: number; e: number; + sw: number; s: number; se: number; + inner_nw: number; inner_ne: number; + inner_sw: number; inner_se: number; + } + + const makeAutotile = (baseRow: number): AutotileSet => ({ + nw: baseRow * C + 0, n: baseRow * C + 1, ne: baseRow * C + 2, + w: (baseRow + 1) * C + 0, c: (baseRow + 1) * C + 1, e: (baseRow + 1) * C + 2, + sw: (baseRow + 2) * C + 0, s: (baseRow + 2) * C + 1, se: (baseRow + 2) * C + 2, + inner_nw: baseRow * C + 3, inner_ne: baseRow * C + 4, + inner_sw: (baseRow + 1) * C + 3, inner_se: (baseRow + 1) * C + 4, + }); + + const GRASS = makeAutotile(0); + const WATER = makeAutotile(3); + const DIRT = makeAutotile(6); + + const getTerr = (x: number, y: number): number => { + if (x < 0 || y < 0 || x >= worldWidth || y >= worldHeight) return Terrain.GRASS; + return terrain[y * worldWidth + x]; + }; + + // Pick autotile based on 8-neighbor context + const pickOverlayTile = (x: number, y: number, terrainType: number): number => { + const tiles = terrainType === Terrain.WATER ? WATER : DIRT; + const same = (dx: number, dy: number) => getTerr(x + dx, y + dy) === terrainType; + const n = same(0, -1), s = same(0, 1), w = same(-1, 0), e = same(1, 0); + const nw = same(-1, -1), ne = same(1, -1), sw = same(-1, 1), se = same(1, 1); + + // Outer corners + if (!n && !w) return tiles.nw; + if (!n && !e) return tiles.ne; + if (!s && !w) return tiles.sw; + if (!s && !e) return tiles.se; + // Edges + if (!n) return tiles.n; + if (!s) return tiles.s; + if (!w) return tiles.w; + if (!e) return tiles.e; + // Inner corners (cardinal matches but diagonal doesn't) + if (!nw) return tiles.inner_nw; + if (!ne) return tiles.inner_ne; + if (!sw) return tiles.inner_sw; + if (!se) return tiles.inner_se; + // Fully surrounded + return tiles.c; + }; + + // Build tile data arrays + const grassData: number[][] = []; // base layer: all grass + const overlayData: number[][] = []; // water/dirt with autotile edges + const decoData: number[][] = []; // tree/bush decorations + + for (let y = 0; y < worldHeight; y++) { + const grassRow: number[] = []; + const overlayRow: number[] = []; + const decoRow: number[] = []; + for (let x = 0; x < worldWidth; x++) { + grassRow.push(GRASS.c); + const t = terrain[y * worldWidth + x]; + overlayRow.push(t !== Terrain.GRASS ? pickOverlayTile(x, y, t) : -1); + const deco = decorations[y * worldWidth + x]; + decoRow.push(deco >= 0 ? deco : -1); } + grassData.push(grassRow); + overlayData.push(overlayRow); + decoData.push(decoRow); } - // Obstacles - for (const obs of obstacles) { - graphics.fillStyle(0x555555, 1); - graphics.fillRect(obs.x * TILE_SIZE, obs.y * TILE_SIZE, TILE_SIZE, TILE_SIZE); - } + // Helper to create a tilemap layer from a 2D data array + const createTileLayer = (data: number[][], name: string, depth: number) => { + const tm = this.make.tilemap({ + data, + tileWidth: TILESET_TILE_SIZE, + tileHeight: TILESET_TILE_SIZE, + }); + const ts = tm.addTilesetImage(name, 'tileset_16x16', TILESET_TILE_SIZE, TILESET_TILE_SIZE, 0, 0)!; + const layer = tm.createLayer(0, ts, 0, 0)!; + layer.setScale(TILESET_SCALE); + layer.setDepth(depth); + return layer; + }; - // Points of interest + createTileLayer(grassData, 'grass', -3); + createTileLayer(overlayData, 'overlay', -2); + createTileLayer(decoData, 'deco', -1); + + // Points of interest (rendered as colored markers on top of terrain) + const graphics = this.add.graphics(); + graphics.setDepth(0); for (const poi of pointsOfInterest) { const color = poi.type === 'food' ? 0xcc8833 : 0x3355cc; - graphics.fillStyle(color, 1); + graphics.fillStyle(color, 0.7); graphics.fillRect( poi.position.x * TILE_SIZE + 8, poi.position.y * TILE_SIZE + 8, diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 03f8f22..b1469f6 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -1,6 +1,7 @@ import { TICK_RATE, BROADCAST_EVERY_N_TICKS } from '@dflike/shared'; import { World } from '../ecs/World.js'; import { GameMap } from '../map/GameMap.js'; +import { generateMap } from '../map/mapGenerator.js'; import { needsDecaySystem } from '../systems/needsDecaySystem.js'; import { npcBrainSystem } from '../systems/npcBrainSystem.js'; import { movementSystem } from '../systems/movementSystem.js'; @@ -26,16 +27,20 @@ export class GameLoop { } private setupMap(): void { - // Add some obstacle clusters for pathfinding interest - for (let x = 10; x <= 14; x++) for (let y = 10; y <= 12; y++) this.map.setObstacle(x, y); - for (let x = 30; x <= 33; x++) for (let y = 25; y <= 28; y++) this.map.setObstacle(x, y); - for (let x = 50; x <= 53; x++) for (let y = 40; y <= 43; y++) this.map.setObstacle(x, y); + const generated = generateMap(this.map.width, this.map.height); - // Points of interest - this.map.addPointOfInterest({ type: 'food', position: { x: 15, y: 15 } }); - this.map.addPointOfInterest({ type: 'food', position: { x: 45, y: 30 } }); - this.map.addPointOfInterest({ type: 'rest', position: { x: 8, y: 8 } }); - this.map.addPointOfInterest({ type: 'rest', position: { x: 55, y: 50 } }); + // Load terrain and decorations + this.map.terrain = generated.terrain; + this.map.decorations = generated.decorations; + this.map.loadObstacles(generated.obstacles); + + // Points of interest from generator + for (const pos of generated.foodPositions) { + this.map.addPointOfInterest({ type: 'food', position: pos }); + } + for (const pos of generated.restPositions) { + this.map.addPointOfInterest({ type: 'rest', position: pos }); + } } private spawnInitialNPCs(count: number): void { diff --git a/server/src/map/GameMap.ts b/server/src/map/GameMap.ts index 6fb6768..e286148 100644 --- a/server/src/map/GameMap.ts +++ b/server/src/map/GameMap.ts @@ -10,6 +10,8 @@ export class GameMap { readonly height: number; private obstacles: Set = new Set(); private pois: PointOfInterest[] = []; + terrain: number[] = []; // flat array of Terrain values + decorations: number[] = []; // flat array of decoration tile indices (-1 = none) constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) { this.width = width; @@ -24,6 +26,12 @@ export class GameMap { this.obstacles.add(this.key(x, y)); } + loadObstacles(obstacleSet: Set): void { + for (const key of obstacleSet) { + this.obstacles.add(key); + } + } + isWalkable(x: number, y: number): boolean { if (x < 0 || y < 0 || x >= this.width || y >= this.height) return false; return !this.obstacles.has(this.key(x, y)); diff --git a/server/src/map/mapGenerator.ts b/server/src/map/mapGenerator.ts new file mode 100644 index 0000000..4d784e9 --- /dev/null +++ b/server/src/map/mapGenerator.ts @@ -0,0 +1,166 @@ +import { WORLD_WIDTH, WORLD_HEIGHT, Terrain } from '@dflike/shared'; + +export interface GeneratedMap { + terrain: number[]; // flat array, Terrain values + decorations: number[]; // flat array, tile index for decoration layer (-1 = none) + obstacles: Set; // "x,y" keys for non-walkable tiles + foodPositions: { x: number; y: number }[]; + restPositions: { x: number; y: number }[]; +} + +// Simple seeded pseudo-random (mulberry32) +function createRng(seed: number) { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function generateMap( + width = WORLD_WIDTH, + height = WORLD_HEIGHT, + seed?: number, +): GeneratedMap { + const rng = createRng(seed ?? (Date.now() & 0xFFFFFFFF)); + + const terrain = new Array(width * height).fill(Terrain.GRASS); + const decorations = new Array(width * height).fill(-1); + const obstacles = new Set(); + + const idx = (x: number, y: number) => y * width + x; + const inBounds = (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height; + const setTerrain = (x: number, y: number, t: number) => { + if (inBounds(x, y)) terrain[idx(x, y)] = t; + }; + const getTerrain = (x: number, y: number) => { + if (!inBounds(x, y)) return -1; + return terrain[idx(x, y)]; + }; + + // --- River --- + // Meander from top to bottom using sine + noise + const riverStartX = Math.floor(width * 0.3 + rng() * width * 0.4); + const riverFreq = 0.08 + rng() * 0.06; + const riverAmp = 6 + rng() * 6; + const riverPhase = rng() * Math.PI * 2; + const riverWidth = 2 + Math.floor(rng() * 2); // 2-3 tiles wide + + for (let y = 0; y < height; y++) { + const centerX = Math.round( + riverStartX + + Math.sin(y * riverFreq + riverPhase) * riverAmp + + Math.sin(y * riverFreq * 2.3 + riverPhase * 1.7) * (riverAmp * 0.3) + ); + for (let dx = -riverWidth; dx <= riverWidth; dx++) { + const x = centerX + dx; + // Slight randomness on edges + if (Math.abs(dx) === riverWidth && rng() < 0.3) continue; + setTerrain(x, y, Terrain.WATER); + } + } + + // --- Ponds --- + const pondCount = 2 + Math.floor(rng() * 2); // 2-3 ponds + for (let p = 0; p < pondCount; p++) { + let px: number, py: number; + let attempts = 0; + // Find a spot that's grass (not already water) + do { + px = 4 + Math.floor(rng() * (width - 8)); + py = 4 + Math.floor(rng() * (height - 8)); + attempts++; + } while (getTerrain(px, py) === Terrain.WATER && attempts < 50); + + const radius = 2 + Math.floor(rng() * 2); // radius 2-3 + for (let dy = -radius - 1; dy <= radius + 1; dy++) { + for (let dx = -radius - 1; dx <= radius + 1; dx++) { + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist <= radius + rng() * 0.8 - 0.4) { + setTerrain(px + dx, py + dy, Terrain.WATER); + } + } + } + } + + // --- Dirt patches --- + const dirtCount = 3 + Math.floor(rng() * 3); // 3-5 patches + for (let d = 0; d < dirtCount; d++) { + const dx = 3 + Math.floor(rng() * (width - 6)); + const dy = 3 + Math.floor(rng() * (height - 6)); + const radius = 2 + Math.floor(rng() * 3); // radius 2-4 + + for (let oy = -radius - 1; oy <= radius + 1; oy++) { + for (let ox = -radius - 1; ox <= radius + 1; ox++) { + const dist = Math.sqrt(ox * ox + oy * oy); + if (dist <= radius + rng() * 1.0 - 0.5) { + const tx = dx + ox; + const ty = dy + oy; + // Only overwrite grass + if (getTerrain(tx, ty) === Terrain.GRASS) { + setTerrain(tx, ty, Terrain.DIRT); + } + } + } + } + } + + // --- Trees (decorations on grass tiles) --- + // Tree tile indices in the tileset (17 cols wide): + // Deciduous tree top: row 2, col 9 = 2*17+9 = 43 (but multi-tile) + // Small bush: row 7, col 9 = 7*17+9 = 128 + // Pine tree: row 3, col 15 = 3*17+15 = 66 + // We'll use a few different tree/bush decoration tiles + const TREE_TILES = [128, 129, 130, 145, 146]; // small plants/bushes from right side of tileset + + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + if (getTerrain(x, y) !== Terrain.GRASS) continue; + + // Check if adjacent to water (trees don't grow right at water's edge) + let nearWater = false; + for (let ny = -1; ny <= 1; ny++) { + for (let nx = -1; nx <= 1; nx++) { + if (getTerrain(x + nx, y + ny) === Terrain.WATER) nearWater = true; + } + } + + const chance = nearWater ? 0.02 : 0.10; + if (rng() < chance) { + decorations[idx(x, y)] = TREE_TILES[Math.floor(rng() * TREE_TILES.length)]; + obstacles.add(`${x},${y}`); + } + } + } + + // Mark water tiles as obstacles + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (terrain[idx(x, y)] === Terrain.WATER) { + obstacles.add(`${x},${y}`); + } + } + } + + // --- Place POIs on accessible grass tiles --- + const findGrassSpot = (): { x: number; y: number } => { + let x: number, y: number; + let attempts = 0; + do { + x = 3 + Math.floor(rng() * (width - 6)); + y = 3 + Math.floor(rng() * (height - 6)); + attempts++; + } while ( + (getTerrain(x, y) !== Terrain.GRASS || obstacles.has(`${x},${y}`)) + && attempts < 200 + ); + return { x, y }; + }; + + const foodPositions = [findGrassSpot(), findGrassSpot()]; + const restPositions = [findGrassSpot(), findGrassSpot()]; + + return { terrain, decorations, obstacles, foodPositions, restPositions }; +} diff --git a/server/src/network/stateSerializer.ts b/server/src/network/stateSerializer.ts index 191535b..326af69 100644 --- a/server/src/network/stateSerializer.ts +++ b/server/src/network/stateSerializer.ts @@ -78,6 +78,8 @@ export function serializeWorldState(world: World, map: GameMap): WorldState { tileSize: TILE_SIZE, obstacles: map.getObstacles(), pointsOfInterest: map.getPointsOfInterest(), + terrain: map.terrain, + decorations: map.decorations, }; } diff --git a/shared/src/constants.ts b/shared/src/constants.ts index e5a3fff..a5721da 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -63,3 +63,16 @@ export const PROPOSAL_EMOTING_DURATION = 30; // 3 seconds - longer for dramatic // Camera mode commands export const MAX_NPC_COUNT = 50; + +// Terrain types for procedural map +export const Terrain = { + GRASS: 0, + WATER: 1, + DIRT: 2, +} as const; +export type Terrain = (typeof Terrain)[keyof typeof Terrain]; + +// Tileset constants (16x16 tileset scaled 3x to match TILE_SIZE=48) +export const TILESET_TILE_SIZE = 16; +export const TILESET_COLS = 17; // 272px / 16px +export const TILESET_SCALE = TILE_SIZE / TILESET_TILE_SIZE; // 3 diff --git a/shared/src/types.ts b/shared/src/types.ts index 47f2a96..78cb024 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -137,6 +137,8 @@ export interface WorldState { tileSize: number; obstacles: Position[]; pointsOfInterest: { type: 'food' | 'rest'; position: Position }[]; + terrain: number[]; // flat array [y * width + x], terrain type per tile + decorations: number[]; // flat array [y * width + x], decoration tile index (-1 = none) } export interface StateUpdate {