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:
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user