feat: switch to LPC tileset with layered autotiling

Replace the 16x16 tileset with LPC Base Assets (32x32 tiles, 1.5x scale).
Uses a 4-layer rendering approach:
- Layer 0: solid grass base (grass.png)
- Layer 1: water transitions (watergrass.png) with LPC autotile edges
- Layer 2: dirt transitions (dirt.png) with LPC autotile edges
- Layer 3: tree canopy decorations (treetop.png)

LPC autotile format: 3x6 grid with inner/outer corners, edges, and fill
tiles. Edge tiles are selected based on 8-neighbor terrain analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 23:40:15 +00:00
parent 4a2819695b
commit 17621fbd59
9 changed files with 101 additions and 79 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+4 -1
View File
@@ -10,7 +10,10 @@ export class BootScene extends Phaser.Scene {
}
preload(): void {
this.load.image('tileset_16x16', 'assets/tileset_16x16.png');
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');
}
create(): void {
+87 -68
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, Terrain, TILESET_TILE_SIZE, TILESET_COLS, TILESET_SCALE } from '@dflike/shared';
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE } from '@dflike/shared';
interface EntitySprite {
sprite: Phaser.GameObjects.Sprite;
@@ -145,108 +145,127 @@ export class GameScene extends Phaser.Scene {
private drawWorld(): void {
const { worldWidth, worldHeight, terrain, decorations, pointsOfInterest } = this.worldState;
// --- 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 ]
// --- LPC autotile layout (3 cols x 6 rows per tileset, 32x32 tiles) ---
// Row 0: [inner-SE=0] [inner-SW=1] [??=2]
// Row 1: [inner-NE=3] [inner-NW=4] [??=5]
// Row 2: [outer-NW=6] [N-edge=7] [outer-NE=8]
// Row 3: [W-edge=9] [center=10] [E-edge=11]
// Row 4: [outer-SW=12][S-edge=13] [outer-SE=14]
// Row 5: [fill-1=15] [fill-2=16] [fill-3=17]
//
// "outer" = convex corner of the terrain island
// "inner" = concave notch (diagonal-only neighbor is the other terrain)
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 FILL = 16; // solid fill tile (row 5, col 1)
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);
// For a GRASS tile, pick the watergrass/dirt transition tile based on
// which neighbors are the "other" terrain type.
// The LPC edge tiles are drawn FROM the grass perspective (grass is the terrain,
// water/dirt is the background bleeding through at edges).
const pickEdgeTile = (x: number, y: number, otherTerrain: number): number => {
const isOther = (dx: number, dy: number) => getTerr(x + dx, y + dy) === otherTerrain;
const n = isOther(0, -1), s = isOther(0, 1), w = isOther(-1, 0), e = isOther(1, 0);
const nw = isOther(-1, -1), ne = isOther(1, -1), sw = isOther(-1, 1), se = isOther(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;
// Outer corners (two cardinal sides are the other terrain)
if (n && w) return 6; // outer-NW: grass only in SE, other terrain in NW
if (n && e) return 8; // outer-NE
if (s && w) return 12; // outer-SW
if (s && e) return 14; // outer-SE
// Edges (one cardinal side is the other terrain)
if (n) return 7; // N-edge: other terrain bleeds from north
if (s) return 13; // S-edge
if (w) return 9; // W-edge
if (e) return 11; // E-edge
// Inner corners (all cardinal = grass, but a diagonal is the other terrain)
if (se) return 0; // inner-SE: other terrain peeks in SE corner
if (sw) return 1; // inner-SW
if (ne) return 3; // inner-NE
if (nw) return 4; // inner-NW
// No adjacent other-terrain at all
return -1;
};
// 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
// Build tile data arrays for each layer
const grassData: number[][] = []; // base: solid grass everywhere
const waterData: number[][] = []; // watergrass transitions + solid water
const dirtData: number[][] = []; // dirt transitions + solid dirt
const decoData: number[][] = []; // tree decorations
for (let y = 0; y < worldHeight; y++) {
const grassRow: number[] = [];
const overlayRow: number[] = [];
const waterRow: number[] = [];
const dirtRow: 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);
// Base: always solid grass
grassRow.push(FILL);
// Water layer
if (t === Terrain.WATER) {
waterRow.push(FILL); // solid water
} else if (t === Terrain.GRASS) {
waterRow.push(pickEdgeTile(x, y, Terrain.WATER));
} else {
waterRow.push(-1);
}
// Dirt layer
if (t === Terrain.DIRT) {
dirtRow.push(FILL); // solid dirt
} else if (t === Terrain.GRASS) {
dirtRow.push(pickEdgeTile(x, y, Terrain.DIRT));
} else {
dirtRow.push(-1);
}
// Decorations
const deco = decorations[y * worldWidth + x];
decoRow.push(deco >= 0 ? deco : -1);
}
grassData.push(grassRow);
overlayData.push(overlayRow);
waterData.push(waterRow);
dirtData.push(dirtRow);
decoData.push(decoRow);
}
// Helper to create a tilemap layer from a 2D data array
const createTileLayer = (data: number[][], name: string, depth: number) => {
// Helper to create a scaled tilemap layer
const createLayer = (
data: number[][],
tilesetName: string,
textureKey: 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 ts = tm.addTilesetImage(
tilesetName, textureKey,
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;
};
createTileLayer(grassData, 'grass', -3);
createTileLayer(overlayData, 'overlay', -2);
createTileLayer(decoData, 'deco', -1);
createLayer(grassData, 'grass', 'lpc_grass', -4);
createLayer(waterData, 'water', 'lpc_watergrass', -3);
createLayer(dirtData, 'dirt', 'lpc_dirt', -2);
createLayer(decoData, 'trees', 'lpc_treetop', -1);
// Points of interest (rendered as colored markers on top of terrain)
// Points of interest markers
const graphics = this.add.graphics();
graphics.setDepth(0);
for (const poi of pointsOfInterest) {
+5 -6
View File
@@ -108,12 +108,11 @@ export function generateMap(
}
// --- 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
// Tree tile indices reference treetop.png (6 cols x 7 rows)
// Pick recognizable canopy center tiles from each of the 4 trees:
// Deciduous 1 center: (1,1) = 7 Deciduous 2 center: (4,1) = 10
// Conifer 1 center: (1,5) = 31 Conifer 2 center: (4,5) = 34
const TREE_TILES = [7, 10, 31, 34];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
+5 -4
View File
@@ -72,7 +72,8 @@ export const Terrain = {
} 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
// LPC tileset constants (32x32 tiles scaled 1.5x to match TILE_SIZE=48)
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)