diff --git a/client/public/assets/tileset_16x16.png b/client/public/assets/tileset_16x16.png new file mode 100644 index 0000000..13ccc5e Binary files /dev/null and b/client/public/assets/tileset_16x16.png differ 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 {