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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 22:45:09 +00:00
parent cee0fde937
commit 4a2819695b
9 changed files with 312 additions and 25 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+4
View File
@@ -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,
+103 -16
View File
@@ -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,
+14 -9
View File
@@ -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 {
+8
View File
@@ -10,6 +10,8 @@ export class GameMap {
readonly height: number;
private obstacles: Set<string> = 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<string>): 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));
+166
View File
@@ -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<string>; // "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<number>(width * height).fill(Terrain.GRASS);
const decorations = new Array<number>(width * height).fill(-1);
const obstacles = new Set<string>();
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 };
}
+2
View File
@@ -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,
};
}
+13
View File
@@ -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
+2
View File
@@ -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 {