diff --git a/apps/game/assets/maps/meadows.map b/apps/game/assets/maps/meadows.map index 43cb6ab..a0b670e 100644 --- a/apps/game/assets/maps/meadows.map +++ b/apps/game/assets/maps/meadows.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,4],[0,3],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,4],[0,1],[1,2],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,4],[0,3],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,1],[10,1],[0,2],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"variants":{"0":[0,11,12],"1":[1,14],"4":[4,13]},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,4],[0,3],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,4],[0,1],[1,2],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,4],[0,3],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,1],[10,1],[0,2],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file diff --git a/apps/game/assets/maps/ponds.map b/apps/game/assets/maps/ponds.map index e3510d3..fc38949 100644 --- a/apps/game/assets/maps/ponds.map +++ b/apps/game/assets/maps/ponds.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"variants":{"0":[0,11,12],"1":[1,14],"4":[4,13]},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file diff --git a/apps/game/assets/maps/zvenets.map b/apps/game/assets/maps/zvenets.map index 43910d8..0b75db0 100644 --- a/apps/game/assets/maps/zvenets.map +++ b/apps/game/assets/maps/zvenets.map @@ -1 +1 @@ -{"format":"rpg-map","version":1,"width":28,"height":20,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"encoding":"rle","tiles":[[4,30],[3,1],[4,6],[0,4],[4,13],[3,1],[4,9],[0,2],[4,2],[0,1],[4,23],[0,3],[4,1],[0,1],[4,12],[3,1],[4,2],[3,1],[4,1],[3,1],[4,5],[0,5],[4,12],[3,1],[4,2],[0,2],[4,6],[0,5],[4,1],[0,1],[4,3],[7,1],[4,6],[3,1],[4,3],[3,1],[4,5],[7,1],[4,1],[10,1],[1,10],[4,8],[3,1],[0,2],[4,6],[1,5],[6,1],[4,1],[1,1],[4,1],[1,2],[4,6],[3,3],[4,1],[3,1],[4,6],[1,8],[4,1],[1,1],[4,6],[3,1],[4,3],[0,1],[4,7],[1,1],[4,1],[1,9],[4,1],[7,1],[4,8],[1,12],[0,1],[1,1],[4,1],[1,3],[4,1],[0,3],[4,4],[3,1],[0,1],[4,7],[1,2],[4,1],[1,8],[0,4],[4,5],[3,1],[0,5],[4,2],[1,11],[4,1],[0,3],[4,6],[3,1],[0,6],[1,8],[4,1],[1,2],[4,1],[0,3],[4,4],[3,1],[4,3],[0,3],[4,1],[0,5],[4,17],[0,1],[4,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,5],[0,4],[4,7],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,2],[0,2],[4,4],[0,5],[4,5],[3,2],[4,2],[0,1],[3,1],[4,5],[0,12],[4,8],[0,3],[4,6],[0,5],[4,1],[0,5],[4,4],[0,1],[3,1],[0,1],[4,1],[3,1],[0,1],[4,7],[0,7],[4,1],[0,3],[4,4],[0,3],[4,1]]} \ No newline at end of file +{"format":"rpg-map","version":1,"width":28,"height":20,"blocked":[2,3,6,7,9,10],"tall":{"3":{"height":1.5,"ground":0},"6":{"height":2.5,"ground":1},"7":{"height":1.75,"ground":4},"9":{"height":1,"ground":8}},"variants":{"0":[0,11,12],"1":[1,14],"4":[4,13]},"encoding":"rle","tiles":[[4,30],[3,1],[4,6],[0,4],[4,13],[3,1],[4,9],[0,2],[4,2],[0,1],[4,23],[0,3],[4,1],[0,1],[4,12],[3,1],[4,2],[3,1],[4,1],[3,1],[4,5],[0,5],[4,12],[3,1],[4,2],[0,2],[4,6],[0,5],[4,1],[0,1],[4,3],[7,1],[4,6],[3,1],[4,3],[3,1],[4,5],[7,1],[4,1],[10,1],[1,10],[4,8],[3,1],[0,2],[4,6],[1,5],[6,1],[4,1],[1,1],[4,1],[1,2],[4,6],[3,3],[4,1],[3,1],[4,6],[1,8],[4,1],[1,1],[4,6],[3,1],[4,3],[0,1],[4,7],[1,1],[4,1],[1,9],[4,1],[7,1],[4,8],[1,12],[0,1],[1,1],[4,1],[1,3],[4,1],[0,3],[4,4],[3,1],[0,1],[4,7],[1,2],[4,1],[1,8],[0,4],[4,5],[3,1],[0,5],[4,2],[1,11],[4,1],[0,3],[4,6],[3,1],[0,6],[1,8],[4,1],[1,2],[4,1],[0,3],[4,4],[3,1],[4,3],[0,3],[4,1],[0,5],[4,17],[0,1],[4,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,5],[0,4],[4,7],[3,1],[0,2],[4,1],[0,1],[4,1],[0,2],[4,2],[0,2],[4,4],[0,5],[4,5],[3,2],[4,2],[0,1],[3,1],[4,5],[0,12],[4,8],[0,3],[4,6],[0,5],[4,1],[0,5],[4,4],[0,1],[3,1],[0,1],[4,1],[3,1],[0,1],[4,7],[0,7],[4,1],[0,3],[4,4],[0,3],[4,1]]} \ No newline at end of file diff --git a/apps/game/assets/tiles/ash_2.png b/apps/game/assets/tiles/ash_2.png new file mode 100644 index 0000000..23546d4 --- /dev/null +++ b/apps/game/assets/tiles/ash_2.png Binary files differ diff --git a/apps/game/assets/tiles/grass_2.png b/apps/game/assets/tiles/grass_2.png new file mode 100644 index 0000000..c76b336 --- /dev/null +++ b/apps/game/assets/tiles/grass_2.png Binary files differ diff --git a/apps/game/assets/tiles/grass_3.png b/apps/game/assets/tiles/grass_3.png new file mode 100644 index 0000000..538dbf3 --- /dev/null +++ b/apps/game/assets/tiles/grass_3.png Binary files differ diff --git a/apps/game/assets/tiles/path_2.png b/apps/game/assets/tiles/path_2.png new file mode 100644 index 0000000..4251c90 --- /dev/null +++ b/apps/game/assets/tiles/path_2.png Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 155a503..27a1816 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -37,10 +37,14 @@ /** Текстуры тайлов и одиночные спрайты NPC. */ static readonly ASSET_KEYS = [ 'tiles/grass', + 'tiles/grass_2', + 'tiles/grass_3', 'tiles/path', + 'tiles/path_2', 'tiles/water_1', 'tiles/water_2', 'tiles/ash', + 'tiles/ash_2', 'tiles/bellflower', 'tiles/tree', 'tiles/house', diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts index 3ae705d..58014ff 100644 --- a/apps/game/src/data/map.ts +++ b/apps/game/src/data/map.ts @@ -12,7 +12,12 @@ HOUSE: 7, FLOOR: 8, WALL: 9, - WELL: 10 + WELL: 10, + // Варианты текстур (микс по хэшу координат) — в tiles никогда не попадают. + GRASS_V1: 11, + GRASS_V2: 12, + ASH_V1: 13, + PATH_V1: 14 } as const; const BLOCKED = [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE, TILES.WALL, TILES.WELL]; @@ -26,6 +31,16 @@ [TILES.WALL]: { height: 1, ground: TILES.FLOOR } }; +/** + * Микс текстур наземных тайлов (движок выбирает по хэшу координат): + * трава и тропа — из трёх/двух вариантов, пепел — с редким вторым оттенком. + */ +const GROUND_VARIANTS: Record = { + [TILES.GRASS]: [TILES.GRASS, TILES.GRASS_V1, TILES.GRASS_V2], + [TILES.PATH]: [TILES.PATH, TILES.PATH_V1], + [TILES.ASH]: [TILES.ASH, TILES.ASH_V1] +}; + /** Детерминированный ГПСЧ, чтобы локация выглядела одинаково между запусками. */ function mulberry32(seed: number): () => number { return () => { @@ -149,7 +164,8 @@ height, tiles, blocked: [...BLOCKED], - tall: TALL_OBJECTS + tall: TALL_OBJECTS, + variants: GROUND_VARIANTS }; } @@ -232,7 +248,8 @@ height, tiles, blocked: [...BLOCKED], - tall: TALL_OBJECTS + tall: TALL_OBJECTS, + variants: GROUND_VARIANTS }; } /** @@ -345,6 +362,7 @@ height, tiles, blocked: [...BLOCKED], - tall: TALL_OBJECTS + tall: TALL_OBJECTS, + variants: GROUND_VARIANTS }; } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 8b1d803..820c5f2 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -652,9 +652,13 @@ const a = this.game.assets; return new Map([ [TILES.GRASS, a.texture('tiles/grass')], + [TILES.GRASS_V1, a.texture('tiles/grass_2')], + [TILES.GRASS_V2, a.texture('tiles/grass_3')], [TILES.PATH, a.texture('tiles/path')], + [TILES.PATH_V1, a.texture('tiles/path_2')], [TILES.WATER, a.texture('tiles/water_1')], [TILES.ASH, a.texture('tiles/ash')], + [TILES.ASH_V1, a.texture('tiles/ash_2')], [TILES.BELLFLOWER, a.texture('tiles/bellflower')], [TILES.TREE, a.texture('tiles/tree')], [TILES.TOWER, a.texture('tiles/tower')], diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index 1ef1aaf..1ca2ff3 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -38,10 +38,10 @@ return c; } -function grassTile() { +function grassTile(seed = 77) { const c = tileBase('G1', 'G2', 'G0', 11, 26); - // Сухие стебли: 1-2 пикселя G2H. - const r = rng(77); + // Сухие стебли: 1-2 пикселя G2H. seed — вариант текстуры (микс по хэшу тайла). + const r = rng(seed); for (let i = 0; i < 5; i++) { const x = 3 + Math.floor(r() * 26); const y = 3 + Math.floor(r() * 10); @@ -52,10 +52,10 @@ return c; } -function pathTile() { +function pathTile(seed = 31) { const c = tileBase('P4', 'P5', 'P3', 23, 30); - // Утоптанный камешек и след колеи. - const r = rng(31); + // Утоптанный камешек и след колеи. seed — вариант текстуры. + const r = rng(seed); for (let i = 0; i < 4; i++) { const x = 2 + Math.floor(r() * 28); const y = 2 + Math.floor(r() * 12); @@ -84,11 +84,11 @@ return c; } -function ashTile() { +function ashTile(seed = 9) { // Пепельный бугор: чуть светлее травы, тоже без обводки. const c = new Canvas(SIZES.tile.w, SIZES.tile.h); fillDiamond(c, SIZES.tile.w / 2, SIZES.tile.h / 2, SIZES.tile.w, SIZES.tile.h, 'P3'); - const r = rng(9); + const r = rng(seed); for (let i = 0; i < 6; i++) { const x = 3 + Math.floor(r() * 26); const y = 4 + Math.floor(r() * 9); @@ -650,10 +650,14 @@ console.log('Генерация тайлов:'); save(grassTile(), 'tiles/grass.png'); +save(grassTile(83), 'tiles/grass_2.png'); +save(grassTile(149), 'tiles/grass_3.png'); save(pathTile(), 'tiles/path.png'); +save(pathTile(57), 'tiles/path_2.png'); save(waterTile(0), 'tiles/water_1.png'); save(waterTile(1), 'tiles/water_2.png'); save(ashTile(), 'tiles/ash.png'); +save(ashTile(41), 'tiles/ash_2.png'); save(bellflowerTile(), 'tiles/bellflower.png'); save(treeTile(), 'tiles/tree.png'); save(houseTile(), 'tiles/house.png'); diff --git a/docs/art-style.md b/docs/art-style.md index 4a674e6..88bc748 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -123,6 +123,7 @@ | 6 | `tiles/tower.png` | башня Звенца — 32×80, tall-объект 2.5 юнита: бак на ногах-рельсах, колокол B0/B2 под баком | | 7 | `tiles/house.png` | дом Звенца — 32×56, tall-объект 1.75 юнита: стены из вагонных щитов, крыша из шпал | | — | `tiles/house_big.png` | большой дом — 64×80, prop-объект footprint 2×2: два модуля щитов, козырёк над дверью, окна с переплётом | +| — | `tiles/grass_2/3.png`, `tiles/path_2.png`, `tiles/ash_2.png` | варианты текстур 32×16 (та же палитра, другой seed шума) — микс по хэшу координат, в `tiles` не попадают | ### Персонажи (16×24) diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index a2a9262..27f87ab 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -55,6 +55,11 @@ тень — нижние). Дерево: «облачная» крона из перекрывающихся окружностей, ствол, свисающий кабель — детали сеттинга. +Варианты текстур (микс в игре по хэшу координат) — та же функция с другим seed: +`grassTile(83)`, `ashTile(41)` и т.д. Палитра одна и та же, различие — рисунок шума, +а не цвета. Все варианты кладутся в `apps/game/assets/tiles/` и перечисляются в +`Game.ASSET_KEYS` + `LocationScene.tileTextures()`. + ## Атласы Если спрайтов много (анимации), генератор может собрать атлас: кадры в один PNG + diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 78f5f29..6cfce74 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -53,9 +53,35 @@ map.worldBounds; // { x: 0, y: 0, width, height } — границы камеры в юнитах map.setTile(x, y, id); // изменить тайл (сбор предметов, посадка) — данные + перерисовка ячейки map.setTileAnimation(id, frames, fps); // живой тайл (вода): один таймлайн на все клетки +map.setTileVariants(id, ids); // микс текстур: клетки id рисуются разными вариантами из ids +map.variantOf(id, x, y); // какой id фактически нарисован на клетке (учитывает варианты) map.update(dt); // тик тайл-анимаций (no-op, если их нет) — из update сцены ``` +### Варианты текстур (микс) + +Одна текстура на все клетки травы выглядит однородно. `variants` в `TileMapData` +(id → список id-шников вариантов, **включая сам id**) заставляет клетки одного +id рисоваться разными текстурами: + +```ts +const data: TileMapData = { + // ... + variants: { [TILE_GRASS]: [TILE_GRASS, TILE_GRASS_V1, TILE_GRASS_V2] } +}; +``` + +Выбор — `hashTile(x, y, id) % ids.length` (см. [math](../engine/README.md)): +детерминированный, без состояния — вид карты стабилен между запусками, при +`setTile` и в сейвах. Рантайм-путь — `setTileVariants(id, ids)`. + +- **Анимация сильнее вариантов**: `setTileAnimation` снимает варианты id; + `setTileVariants` на анимированном id — no-op (воду не миксуют). +- В файле карты (`MapFileV1`) ключи строковые (`"variants": {"0": [0, 5, 9]}`), + `parseMap` валидирует и возвращает с числовыми ключами. Поле опционально. +- id вариантов — обычные id тайлов (нужны текстуры в общей таблице); в `tiles` + они никогда не попадают. + Подробнее про анимированные тайлы — [anim.md](anim.md). ## Крупные объекты: props (footprint w×h) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 20934a1..d88fe28 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -82,7 +82,7 @@ DEFAULT_ISO, type IsoLayout } from './math/iso'; -export { createRng, type Rng } from './math/rng'; +export { createRng, hashTile, type Rng } from './math/rng'; export { inCircle, inCircleW, diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index a14fa4e..fd73a0f 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -1,6 +1,7 @@ import { Container, Graphics, Texture, Sprite } from 'pixi.js'; import { Grid } from './pathfinding'; import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso'; +import { hashTile } from '../math/rng'; /** * Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или, @@ -63,6 +64,12 @@ tall?: Record; /** Крупные объекты с footprint'ом в несколько тайлов. */ props?: PropData[]; + /** + * Варианты текстур: id -> список id-шников вариантов (включая сам id). + * Клетка рисуется одним из вариантов — детерминированно по (x, y, id), + * поэтому вид карты стабилен между запусками и при setTile. + */ + variants?: Record; } /** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */ @@ -100,6 +107,8 @@ private cells = new Map(); private anims = new Map(); private spriteAnims = new Map(); + /** Варианты текстур по id (из данных или setTileVariants). */ + private variants = new Map(); get width(): number { return this.data.width; @@ -114,6 +123,9 @@ this.iso = iso; this.textures = textures; this.blockedSet = new Set(data.blocked); + for (const [id, ids] of Object.entries(data.variants ?? {})) { + this.variants.set(Number(id), ids); + } this.tallSpecs = new Map( Object.entries(data.tall ?? {}).map(([id, spec]) => [ Number(id), @@ -221,9 +233,11 @@ * Анимированный тайл: клетки с этим id переключают кадры по одному общему * таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже * нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...). + * Анимация сильнее вариантов: варианты этого id снимаются. */ setTileAnimation(id: number, frames: Texture[], fps: number): void { if (frames.length === 0 || fps <= 0) return; + this.variants.delete(id); this.anims.set(id, { frames, fps, t: 0, sprites: new Set() }); for (const cell of this.cells.values()) { if (cell.ground && cell.groundId === id) this.registerAnim(cell.ground, id); @@ -245,6 +259,21 @@ this.spriteAnims.set(sprite, id); } + /** + * Рантайм-варианты текстур (движок не знает, что это — трава или пепел): + * клетки id рисуются детерминированным выбором из ids (включая сам id). + * Анимированный id вариантов не получает — анимация сильнее. + */ + setTileVariants(id: number, ids: number[]): void { + if (ids.length === 0 || this.anims.has(id)) return; + this.variants.set(id, ids); + for (const [key, cell] of this.cells) { + if (cell.groundId === id || cell.objectId === id) { + this.drawCell(key % this.data.width, Math.floor(key / this.data.width)); + } + } + } + private unregisterAnim(sprite: Sprite): void { const id = this.spriteAnims.get(sprite); if (id === undefined) return; @@ -267,8 +296,9 @@ const p = isoToScreen(tx, ty, iso); // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). - const groundId = tall?.ground ?? id; - fresh.groundId = groundId; + const groundBase = tall?.ground ?? id; + const groundId = this.variantOf(groundBase, tx, ty); + fresh.groundId = groundBase; // для матчинга анимаций — базовый id const groundTex = this.textures.get(groundId); if (groundTex) { const s = new Sprite(groundTex); @@ -276,7 +306,7 @@ s.position.set(p.x, p.y); this.groundLayer.addChild(s); fresh.ground = s; - this.registerAnim(s, groundId); + this.registerAnim(s, groundBase); } else { // Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок). const g = new Graphics(); @@ -290,7 +320,7 @@ // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. if (tall) { fresh.objectId = id; - const objTex = this.textures.get(id); + const objTex = this.textures.get(this.variantOf(id, tx, ty)); if (objTex) { const s = new Sprite(objTex); s.anchor.set(0.5, 1); @@ -304,6 +334,15 @@ } } + /** + * Вариант id для клетки: детерминированный выбор по хэшу (без анимации). + * Публичный — игре полезно знать, какой id фактически нарисован. + */ + variantOf(id: number, tx: number, ty: number): number { + const ids = this.variants.get(id); + return ids ? ids[hashTile(tx, ty, id) % ids.length]! : id; + } + isWalkable(x: number, y: number): boolean { if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false; if (this.propBlocked.has(y * this.data.width + x)) return false; diff --git a/packages/engine/src/map/__tests__/isometricMap.test.ts b/packages/engine/src/map/__tests__/isometricMap.test.ts index 9b6a68b..35853a3 100644 --- a/packages/engine/src/map/__tests__/isometricMap.test.ts +++ b/packages/engine/src/map/__tests__/isometricMap.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { Texture } from 'pixi.js'; import { IsometricTileMap, type TileMapData } from '../IsometricTileMap'; function makeData(): TileMapData { @@ -35,6 +36,59 @@ }); }); +describe('IsometricTileMap.variants', () => { + it('вариант выбирается по хэшу координат и стабилен при setTile', () => { + const map = new IsometricTileMap(makeDataWithVariants(), new Map()); + + const variant = map.variantOf(0, 3, 2); + expect([0, 1, 2]).toContain(variant); + // второй экземпляр той же карты — тот же выбор (детерминизм) + const again = new IsometricTileMap(makeDataWithVariants(), new Map()); + expect(again.variantOf(0, 3, 2)).toBe(variant); + + // setTile других id не меняет выбор для id с вариантами + map.setTile(1, 1, 2); + expect(map.variantOf(0, 3, 2)).toBe(variant); + // setTile на клетке с id из variants — вариант той же клетки стабилен + map.setTile(3, 2, 2); + map.setTile(3, 2, 0); + expect(map.variantOf(0, 3, 2)).toBe(variant); + }); + + it('setTileVariants задаёт таблицу рантаймом, пустой массив — no-op', () => { + const map = new IsometricTileMap(makeData(), new Map()); + expect(map.variantOf(0, 2, 2)).toBe(0); // без вариантов id как есть + map.setTileVariants(0, [0, 5]); + // выбор детерминирован и покидает только варианты + const v22 = map.variantOf(0, 2, 2); + const v32 = map.variantOf(0, 3, 2); + expect([0, 5]).toContain(v22); + expect([0, 5]).toContain(v32); + // выбор стабилен + expect(map.variantOf(0, 2, 2)).toBe(v22); + expect(map.variantOf(0, 3, 2)).toBe(v32); + map.setTileVariants(0, []); // пустой массив — no-op (таблица не сбрасывается) + expect(map.variantOf(0, 2, 2)).toBe(v22); + // id без вариантов рисуется как есть + expect(map.variantOf(2, 3, 2)).toBe(2); + }); + + it('анимация сильнее: setTileVariants на анимированном id — no-op, setTileAnimation снимает варианты', () => { + const map = new IsometricTileMap(makeData(), new Map()); + map.setTileAnimation(0, [Texture.WHITE], 4); + map.setTileVariants(0, [0, 5]); // no-op + expect(map.variantOf(0, 3, 2)).toBe(0); + + const withVariants = new IsometricTileMap(makeDataWithVariants(), new Map()); + withVariants.setTileAnimation(0, [Texture.WHITE], 4); + expect(withVariants.variantOf(0, 3, 2)).toBe(0); // варианты сняты + }); +}); + +function makeDataWithVariants(): TileMapData { + return { ...makeData(), variants: { 0: [0, 1, 2] } }; +} + describe('IsometricTileMap.props', () => { it('блокирует весь footprint и нормализует размеры', () => { const data = makeData(); diff --git a/packages/engine/src/map/__tests__/mapFormat.test.ts b/packages/engine/src/map/__tests__/mapFormat.test.ts index 8dbfcb1..408445d 100644 --- a/packages/engine/src/map/__tests__/mapFormat.test.ts +++ b/packages/engine/src/map/__tests__/mapFormat.test.ts @@ -60,6 +60,24 @@ expect(parsed.props).toEqual(withProps.props); }); + it('roundtrip сохраняет variants (строковые ключи файла -> числовые)', () => { + const withVariants: TileMapData = { + ...sample, + variants: { 0: [0, 5, 9], 12: [12] } + }; + const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withVariants)))); + expect(parsed.variants).toEqual(withVariants.variants); + }); + + it('parseMap отвергает пустой/нечисловой variants-массив', () => { + expect(() => parseMap({ ...encodeMap(sample), variants: { 0: [] } })).toThrow(/variants/); + expect(() => parseMap({ ...encodeMap(sample), variants: { 0: [1.5] } })).toThrow(/variants/); + expect(() => parseMap({ ...encodeMap(sample), variants: { 0: ['a'] } })).toThrow(/variants/); + // отсутствует/пустая таблица — ок (старые карты) + expect(parseMap(encodeMap(sample)).variants).toBeUndefined(); + expect(parseMap({ ...encodeMap(sample), variants: {} }).variants).toBeUndefined(); + }); + it('parseMap отвергает проп за границей карты', () => { // карта 3×2, footprint 2×2 от (2, 1) вылезает вправо и вниз const file = encodeMap({ ...sample, props: [{ id: 7, x: 2, y: 1, w: 2, h: 2 }] }); diff --git a/packages/engine/src/map/mapFormat.ts b/packages/engine/src/map/mapFormat.ts index f52a03e..0ea3728 100644 --- a/packages/engine/src/map/mapFormat.ts +++ b/packages/engine/src/map/mapFormat.ts @@ -17,6 +17,8 @@ tall?: Record; /** Крупные объекты с footprint'ом в несколько тайлов. */ props?: PropData[]; + /** Варианты текстур: id -> список id-шников вариантов (ключи — строки JSON). */ + variants?: Record; /** 'rle' — tiles как пары [id, runLength]; 'raw' — плоский массив. */ encoding: 'rle' | 'raw'; tiles: number[] | [number, number][]; @@ -57,6 +59,9 @@ blocked: data.blocked, tall: data.tall, props: data.props, + variants: data.variants + ? Object.fromEntries(Object.entries(data.variants).map(([id, ids]) => [String(id), ids])) + : undefined, encoding, tiles: encoding === 'rle' ? rleEncode(data.tiles) : data.tiles }; @@ -99,16 +104,31 @@ } } validateProps(f.props ?? [], f.width, f.height); + const variants = validateVariants(f.variants); return { width: f.width, height: f.height, tiles, blocked: f.blocked ?? [], tall: f.tall, - props: f.props + props: f.props, + variants }; } +/** Валидация вариантов: непустые массивы целых id (пустой объект -> undefined). */ +function validateVariants(variants: Record | undefined): Record | undefined { + if (!variants || Object.keys(variants).length === 0) return undefined; + const out: Record = {}; + for (const [key, ids] of Object.entries(variants)) { + if (!Array.isArray(ids) || ids.length === 0 || ids.some((v) => !Number.isInteger(v))) { + throw new Error(`Карта: variants[${key}] — ожидался непустой массив целых id`); + } + out[Number(key)] = ids; + } + return out; +} + /** Валидация пропов: целые w/h ≥ 1 и footprint целиком внутри карты. */ function validateProps(props: PropData[], width: number, height: number): void { for (const p of props) { diff --git a/packages/engine/src/math/__tests__/rng.test.ts b/packages/engine/src/math/__tests__/rng.test.ts index 7f0c206..5c49d04 100644 --- a/packages/engine/src/math/__tests__/rng.test.ts +++ b/packages/engine/src/math/__tests__/rng.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createRng } from '../rng'; +import { createRng, hashTile } from '../rng'; describe('createRng (mulberry32)', () => { it('одинаковый seed — одинаковая последовательность', () => { @@ -80,4 +80,33 @@ b.next(); expect(b.state()).toBe(s2); }); +}); + +describe('hashTile', () => { + it('детерминирован и не зависит от порядка вызовов', () => { + expect(hashTile(3, 5, 7)).toBe(hashTile(3, 5, 7)); + // отсутствие состояния: значения не «плывут» между вызовами + const first = hashTile(0, 0, 1); + hashTile(10, 20, 2); + hashTile(30, 40, 3); + expect(hashTile(0, 0, 1)).toBe(first); + }); + + it('разные координаты/seed — разные хэши (в массе)', () => { + const seen = new Set(); + for (let x = 0; x < 20; x++) { + for (let y = 0; y < 20; y++) seen.add(hashTile(x, y, 42)); + } + // 400 тайлов — коллизий почти нет (32-битный хэш) + expect(seen.size).toBeGreaterThan(390); + }); + + it('возвращает неотрицательное целое', () => { + for (let i = -5; i <= 5; i++) { + const h = hashTile(i, i * 3, 9); + expect(Number.isInteger(h)).toBe(true); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThan(2 ** 32); + } + }); }); \ No newline at end of file diff --git a/packages/engine/src/math/rng.ts b/packages/engine/src/math/rng.ts index c5fbe59..41a008c 100644 --- a/packages/engine/src/math/rng.ts +++ b/packages/engine/src/math/rng.ts @@ -36,4 +36,17 @@ chance: (p) => next() < p, state: () => s }; +} + +/** + * Детерминированный хэш координат тайла без состояния: одинаковые (x, y, seed) + * — одинаковый результат навсегда. В отличие от createRng не потребляет + * последовательность и не зависит от порядка вызовов — годится для выбора + * варианта текстуры по тайлу (hashTile(x, y, id) % варианты.length). + */ +export function hashTile(x: number, y: number, seed = 0): number { + let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 2654435761)) >>> 0; + h = (h ^ (h >>> 13)) >>> 0; + h = Math.imul(h, 1274126177) >>> 0; + return (h ^ (h >>> 16)) >>> 0; } \ No newline at end of file