feat: multi-tile trees with treesnstone tileset and trunk bridging

Replace single-tile tree rendering with 6 multi-tile tree variants using
the treesnstone.png tileset. Each tree is 3 tiles wide with canopy tiles
on the decorations layer (above characters) and ground/shadow tiles on
the trunkDecorations layer (below characters). Add trunk bridge tiles
(tile 41) behind transparent canopy areas to connect canopy to ground
visually, fixing floating canopy appearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-10 01:22:47 +00:00
parent c2b22aab4e
commit 034f799431
6 changed files with 185 additions and 96 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -2
View File
@@ -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 {
+9 -67
View File
@@ -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<void> {
@@ -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);
}
});
+169 -19
View File
@@ -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<string>();
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' });
}
}
+1 -1
View File
@@ -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)