diff --git a/client/public/assets/lpc/treesnstone.png b/client/public/assets/lpc/treesnstone.png new file mode 100644 index 0000000..d034e14 Binary files /dev/null and b/client/public/assets/lpc/treesnstone.png differ diff --git a/client/src/scenes/BootScene.ts b/client/src/scenes/BootScene.ts index 1863653..770224a 100644 --- a/client/src/scenes/BootScene.ts +++ b/client/src/scenes/BootScene.ts @@ -13,8 +13,7 @@ export class BootScene extends Phaser.Scene { this.load.image('lpc_grass', 'assets/lpc/grass.png'); this.load.image('lpc_watergrass', 'assets/lpc/watergrass.png'); this.load.image('lpc_dirt', 'assets/lpc/dirt.png'); - this.load.image('lpc_treetop', 'assets/lpc/treetop.png'); - this.load.image('lpc_trunk', 'assets/lpc/trunk.png'); + this.load.image('lpc_treesnstone', 'assets/lpc/treesnstone.png'); } create(): void { diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 27a1c68..9354dc1 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -435,11 +435,9 @@ export class GameScene extends Phaser.Scene { const grassData: number[][] = []; // base: solid grass everywhere const waterData: number[][] = []; // watergrass transitions + solid water const dirtData: number[][] = []; // dirt transitions + solid dirt - const moundData: number[][] = []; // grass mound at tree bases - const trunkData: number[][] = []; // tree trunks - const decoData: number[][] = []; // tree canopy decorations + const trunkData: number[][] = []; // tree shade + stone tiles (below characters) + const decoData: number[][] = []; // tree canopy decorations (above characters) - // First pass: build terrain layers + trunk/canopy for (let y = 0; y < worldHeight; y++) { const grassRow: number[] = []; const waterRow: number[] = []; @@ -469,11 +467,11 @@ export class GameScene extends Phaser.Scene { dirtRow.push(-1); } - // Trunk decorations + // Trunk/ground decorations (shade + stones) const trunk = trunkDecorations[y * worldWidth + x]; trunkRow.push(trunk >= 0 ? trunk : -1); - // Canopy decorations + // Canopy decorations (tree body) const deco = decorations[y * worldWidth + x]; decoRow.push(deco >= 0 ? deco : -1); } @@ -485,37 +483,6 @@ export class GameScene extends Phaser.Scene { decoData.push(decoRow); } - // Second pass: generate grass mound at tree bases from trunk positions. - // Trunk center-bottom tiles (7=type1, 10=type2) mark the tree anchor. - // Place grass.png 3×3 island tiles (rows 2-4) centered at and below the anchor. - // grass island: row2=[6,7,8] row3=[9,10,11] row4=[12,13,14] - const MOUND_OFFSETS = [ - // Top row of mound at same level as trunk bottom - { dx: -1, dy: 0, tile: 6 }, { dx: 0, dy: 0, tile: 7 }, { dx: 1, dy: 0, tile: 8 }, - // Bottom row below trunk - { dx: -1, dy: 1, tile: 12 }, { dx: 0, dy: 1, tile: 13 }, { dx: 1, dy: 1, tile: 14 }, - ]; - - for (let y = 0; y < worldHeight; y++) { - const moundRow: number[] = new Array(worldWidth).fill(-1); - moundData.push(moundRow); - } - - for (let y = 0; y < worldHeight; y++) { - for (let x = 0; x < worldWidth; x++) { - const trunk = trunkDecorations[y * worldWidth + x]; - // Check for trunk center-bottom (tile 7 for type 1, tile 10 for type 2) - if (trunk === 7 || trunk === 10) { - for (const { dx, dy, tile } of MOUND_OFFSETS) { - const mx = x + dx, my = y + dy; - if (mx >= 0 && mx < worldWidth && my >= 0 && my < worldHeight) { - moundData[my][mx] = tile; - } - } - } - } - } - // Helper to create a scaled tilemap layer const createLayer = ( data: number[][], @@ -542,13 +509,14 @@ export class GameScene extends Phaser.Scene { createLayer(grassData, 'grass', 'lpc_grass', -10); createLayer(waterData, 'water', 'lpc_watergrass', -9); createLayer(dirtData, 'dirt', 'lpc_dirt', -8); - createLayer(moundData, 'mound', 'lpc_grass', -7); - // Tree layers — in front of characters so NPCs walk "behind" trees. + // Tree shade + stone tiles — behind characters + createLayer(trunkData, 'ground_deco', 'lpc_treesnstone', -7); + + // Tree canopy — in front of characters so NPCs walk "behind" trees. // Characters use depth = y * TILE_SIZE (0 to worldHeight * TILE_SIZE). const aboveCharDepth = worldHeight * TILE_SIZE + 1; - createLayer(trunkData, 'trunk', 'lpc_trunk', aboveCharDepth); - createLayer(decoData, 'trees', 'lpc_treetop', aboveCharDepth + 1); + createLayer(decoData, 'trees', 'lpc_treesnstone', aboveCharDepth); // Points of interest markers const graphics = this.add.graphics(); @@ -562,32 +530,6 @@ export class GameScene extends Phaser.Scene { TILE_SIZE - 16, ); } - - // Stone deposit markers - const stoneGraphics = this.add.graphics(); - stoneGraphics.setDepth(-6); - for (let y = 0; y < worldHeight; y++) { - for (let x = 0; x < worldWidth; x++) { - if (terrain[y * worldWidth + x] === Terrain.STONE) { - // Gray stone tile with slight variation - stoneGraphics.fillStyle(0x888888, 0.8); - stoneGraphics.fillRect( - x * TILE_SIZE, - y * TILE_SIZE, - TILE_SIZE, - TILE_SIZE, - ); - // Darker border for definition - stoneGraphics.lineStyle(1, 0x666666, 0.6); - stoneGraphics.strokeRect( - x * TILE_SIZE + 1, - y * TILE_SIZE + 1, - TILE_SIZE - 2, - TILE_SIZE - 2, - ); - } - } - } } private async spawnEntities(entities: EntityState[]): Promise { diff --git a/server/src/map/__tests__/mapGenerator.test.ts b/server/src/map/__tests__/mapGenerator.test.ts index 5b0b74e..52072ea 100644 --- a/server/src/map/__tests__/mapGenerator.test.ts +++ b/server/src/map/__tests__/mapGenerator.test.ts @@ -65,14 +65,12 @@ describe('generateMap — resource generation', () => { expect(trunkCount).toBeGreaterThan(0); }); - it('tree trunk tiles are marked as obstacles', () => { + it('tree log resource tiles are marked as obstacles', () => { const map = generateMap(32, 32, 42); - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - if (map.trunkDecorations[y * 32 + x] !== -1) { - expect(map.obstacles.has(`${x},${y}`)).toBe(true); - } - } + const logResources = map.resourceTiles.filter(r => r.resourceType === 'log'); + expect(logResources.length).toBeGreaterThan(0); + for (const r of logResources) { + expect(map.obstacles.has(`${r.x},${r.y}`)).toBe(true); } }); diff --git a/server/src/map/mapGenerator.ts b/server/src/map/mapGenerator.ts index bc53d56..2bdcd26 100644 --- a/server/src/map/mapGenerator.ts +++ b/server/src/map/mapGenerator.ts @@ -25,6 +25,125 @@ function randRange(rng: () => number, min: number, max: number): number { return min + Math.floor(rng() * (max - min + 1)); } +// --- Multi-tile tree variant definitions --- +// Tile indices are 0-based for treesnstone.png (8 cols × 7 rows, 32px tiles). +// Offsets are relative to anchor (center of trunk row, which is the obstacle tile). +// canopy = decorations layer (above characters), ground = trunkDecorations layer (below characters). + +interface TreeTileOffset { + dx: number; + dy: number; + tile: number; +} + +interface TreeVariant { + canopy: TreeTileOffset[]; + ground: TreeTileOffset[]; + topOffset: number; // negative: highest canopy row relative to anchor + bottomOffset: number; // positive: lowest shade row relative to anchor +} + +const TRUNK_TILE = 41; + +const TREE_VARIANTS: TreeVariant[] = [ + // Tree 1: 5 tall (3 canopy rows + 2 shade rows) + { + canopy: [ + { dx: -1, dy: -2, tile: 9 }, { dx: 0, dy: -2, tile: 10 }, { dx: 1, dy: -2, tile: 11 }, + { dx: -1, dy: -1, tile: 12 }, { dx: 0, dy: -1, tile: 13 }, { dx: 1, dy: -1, tile: 14 }, + { dx: -1, dy: 0, tile: 15 }, { dx: 0, dy: 0, tile: 16 }, { dx: 1, dy: 0, tile: 17 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 48 }, { dx: 0, dy: 1, tile: 49 }, { dx: 1, dy: 1, tile: 50 }, + { dx: -1, dy: 2, tile: 51 }, { dx: 0, dy: 2, tile: 52 }, { dx: 1, dy: 2, tile: 53 }, + ], + topOffset: -2, + bottomOffset: 2, + }, + // Tree 2: 5 tall + { + canopy: [ + { dx: -1, dy: -2, tile: 18 }, { dx: 0, dy: -2, tile: 19 }, { dx: 1, dy: -2, tile: 20 }, + { dx: -1, dy: -1, tile: 21 }, { dx: 0, dy: -1, tile: 22 }, { dx: 1, dy: -1, tile: 23 }, + { dx: -1, dy: 0, tile: 24 }, { dx: 0, dy: 0, tile: 25 }, { dx: 1, dy: 0, tile: 26 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 48 }, { dx: 0, dy: 1, tile: 49 }, { dx: 1, dy: 1, tile: 50 }, + { dx: -1, dy: 2, tile: 51 }, { dx: 0, dy: 2, tile: 52 }, { dx: 1, dy: 2, tile: 53 }, + ], + topOffset: -2, + bottomOffset: 2, + }, + // Tree 3: 6 tall (extra canopy row at top, same tiles as Tree 1 shifted up) + { + canopy: [ + { dx: -1, dy: -3, tile: 9 }, { dx: 0, dy: -3, tile: 10 }, { dx: 1, dy: -3, tile: 11 }, + { dx: -1, dy: -2, tile: 12 }, { dx: 0, dy: -2, tile: 13 }, { dx: 1, dy: -2, tile: 14 }, + { dx: -1, dy: -1, tile: 15 }, { dx: 0, dy: -1, tile: 16 }, { dx: 1, dy: -1, tile: 17 }, + ], + ground: [ + { dx: 0, dy: -2, tile: TRUNK_TILE }, { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 48 }, { dx: 0, dy: 1, tile: 49 }, { dx: 1, dy: 1, tile: 50 }, + { dx: -1, dy: 2, tile: 51 }, { dx: 0, dy: 2, tile: 52 }, { dx: 1, dy: 2, tile: 53 }, + ], + topOffset: -3, + bottomOffset: 2, + }, + // Tree 4: 5 tall + { + canopy: [ + { dx: -1, dy: -2, tile: 0 }, { dx: 0, dy: -2, tile: 1 }, { dx: 1, dy: -2, tile: 2 }, + { dx: -1, dy: -1, tile: 3 }, { dx: 0, dy: -1, tile: 4 }, { dx: 1, dy: -1, tile: 5 }, + { dx: -1, dy: 0, tile: 6 }, { dx: 0, dy: 0, tile: 7 }, { dx: 1, dy: 0, tile: 8 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 42 }, { dx: 0, dy: 1, tile: 43 }, { dx: 1, dy: 1, tile: 44 }, + { dx: -1, dy: 2, tile: 45 }, { dx: 0, dy: 2, tile: 46 }, { dx: 1, dy: 2, tile: 47 }, + ], + topOffset: -2, + bottomOffset: 2, + }, + // Tree 5: 6 tall (4 canopy rows) + { + canopy: [ + { dx: -1, dy: -3, tile: 27 }, { dx: 0, dy: -3, tile: 28 }, { dx: 1, dy: -3, tile: 29 }, + { dx: -1, dy: -2, tile: 30 }, { dx: 0, dy: -2, tile: 31 }, { dx: 1, dy: -2, tile: 32 }, + { dx: -1, dy: -1, tile: 33 }, { dx: 0, dy: -1, tile: 34 }, { dx: 1, dy: -1, tile: 35 }, + { dx: -1, dy: 0, tile: 36 }, { dx: 0, dy: 0, tile: 37 }, { dx: 1, dy: 0, tile: 38 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 42 }, { dx: 0, dy: 1, tile: 43 }, { dx: 1, dy: 1, tile: 44 }, + { dx: -1, dy: 2, tile: 45 }, { dx: 0, dy: 2, tile: 46 }, { dx: 1, dy: 2, tile: 47 }, + ], + topOffset: -3, + bottomOffset: 2, + }, + // Tree 6: 7 tall (5 canopy rows, tallest variant) + { + canopy: [ + { dx: -1, dy: -4, tile: 0 }, { dx: 0, dy: -4, tile: 1 }, { dx: 1, dy: -4, tile: 2 }, + { dx: -1, dy: -3, tile: 3 }, { dx: 0, dy: -3, tile: 4 }, { dx: 1, dy: -3, tile: 5 }, + { dx: -1, dy: -2, tile: 6 }, { dx: 0, dy: -2, tile: 7 }, { dx: 1, dy: -2, tile: 8 }, + { dx: -1, dy: -1, tile: 33 }, { dx: 0, dy: -1, tile: 34 }, { dx: 1, dy: -1, tile: 35 }, + { dx: -1, dy: 0, tile: 36 }, { dx: 0, dy: 0, tile: 37 }, { dx: 1, dy: 0, tile: 38 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 42 }, { dx: 0, dy: 1, tile: 43 }, { dx: 1, dy: 1, tile: 44 }, + { dx: -1, dy: 2, tile: 45 }, { dx: 0, dy: 2, tile: 46 }, { dx: 1, dy: 2, tile: 47 }, + ], + topOffset: -4, + bottomOffset: 2, + }, +]; + +const LARGE_STONE_TILE = 39; +const SMALL_STONE_TILE = 40; + export function generateMap( width = WORLD_WIDTH, height = WORLD_HEIGHT, @@ -116,37 +235,68 @@ export function generateMap( const key = `${x},${y}`; if (!reserved.has(key) && !obstacles.has(key) && terrain[y * width + x] === Terrain.GRASS) { terrain[y * width + x] = Terrain.STONE; + trunkDecorations[y * width + x] = rng() < 0.5 ? LARGE_STONE_TILE : SMALL_STONE_TILE; resourceTiles.push({ x, y, resourceType: 'stone' }); } } } - // --- Trees --- - const treeClusterCount = randRange(rng, 8, 14); + // --- Trees (multi-tile, 3 tiles wide) --- + const usedTiles = new Set(); + const treeClusterCount = randRange(rng, 5, 10); for (let c = 0; c < treeClusterCount; c++) { - const cx = randRange(rng, 3, width - 4); - const cy = randRange(rng, 3, height - 4); - const treeCount = randRange(rng, 2, 5); + const cx = randRange(rng, 6, width - 7); + const cy = randRange(rng, 6, height - 5); + const treeCount = randRange(rng, 1, 3); for (let t = 0; t < treeCount; t++) { - const x = cx + randRange(rng, -2, 2); - const y = cy + randRange(rng, -2, 2); - if (x < 1 || x >= width - 1 || y < 1 || y >= height - 1) continue; - const key = `${x},${y}`; - if (reserved.has(key) || obstacles.has(key) || terrain[y * width + x] !== Terrain.GRASS) continue; + const x = cx + randRange(rng, -4, 4); + const y = cy + randRange(rng, -3, 3); + const variant = TREE_VARIANTS[randRange(rng, 0, TREE_VARIANTS.length - 1)]; - // Trunk at (x, y) — obstacle and resource tile - const trunkTile = randRange(rng, 0, 5); - trunkDecorations[y * width + x] = trunkTile; - obstacles.add(key); + // Check bounds + if (x - 1 < 0 || x + 1 >= width) continue; + if (y + variant.topOffset < 0 || y + variant.bottomOffset >= height) continue; - // Canopy at (x, y-1) — visual only - if (y > 0) { - const canopyTile = randRange(rng, 0, 5); - decorations[(y - 1) * width + x] = canopyTile; + // Check clearance: 3-wide footprint from top to bottom + let blocked = false; + for (let dy = variant.topOffset; dy <= variant.bottomOffset && !blocked; dy++) { + for (let dx = -1; dx <= 1 && !blocked; dx++) { + const tx = x + dx; + const ty = y + dy; + const key = `${tx},${ty}`; + if (reserved.has(key) || obstacles.has(key) || usedTiles.has(key)) { + blocked = true; + } + if (terrain[ty * width + tx] !== Terrain.GRASS) { + blocked = true; + } + } + } + if (blocked) continue; + + // Place canopy tiles (decorations layer, above characters) + for (const { dx, dy, tile } of variant.canopy) { + const tx = x + dx; + const ty = y + dy; + if (tx >= 0 && tx < width && ty >= 0 && ty < height) { + decorations[ty * width + tx] = tile; + usedTiles.add(`${tx},${ty}`); + } } - // Mark trunk tile as log resource + // Place ground/shade tiles (trunkDecorations layer, below characters) + for (const { dx, dy, tile } of variant.ground) { + const tx = x + dx; + const ty = y + dy; + if (tx >= 0 && tx < width && ty >= 0 && ty < height) { + trunkDecorations[ty * width + tx] = tile; + usedTiles.add(`${tx},${ty}`); + } + } + + // Mark anchor as obstacle and log resource + obstacles.add(`${x},${y}`); resourceTiles.push({ x, y, resourceType: 'log' }); } } diff --git a/shared/src/constants.ts b/shared/src/constants.ts index d120996..7622442 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -98,4 +98,4 @@ export type Terrain = (typeof Terrain)[keyof typeof Terrain]; export const TILESET_TILE_SIZE = 32; export const LPC_COLS = 3; // terrain tilesets are 3 cols wide (96px / 32px) export const TILESET_SCALE = TILE_SIZE / TILESET_TILE_SIZE; // 1.5 -export const TREETOP_COLS = 6; // treetop.png is 6 cols wide (192px / 32px) +export const TREE_TILESET_COLS = 8; // treesnstone.png is 8 cols wide (256px / 32px)