diff --git a/assetsraw/chunks/chunk_0_0_0.json b/assetsraw/chunks/chunk_0_0_0.json index 7e3e7af2..78d4c53e 100644 --- a/assetsraw/chunks/chunk_0_0_0.json +++ b/assetsraw/chunks/chunk_0_0_0.json @@ -1,1215 +1,2183 @@ { - "meshes": [ - { - "file": "house_5_3.dmf", - "pos": [ 1, 1, 0 ] - } - ], "tiles": [ { - "pos": [ 0, 0, 0 ], + "pos": [ + 0, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 0, 0 ], + "pos": [ + 1, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 0, 0 ], + "pos": [ + 2, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 0, 0 ], + "pos": [ + 3, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 0, 0 ], + "pos": [ + 4, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 0, 0 ], + "pos": [ + 5, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 0, 0 ], + "pos": [ + 6, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 0, 0 ], + "pos": [ + 7, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 0, 0 ], + "pos": [ + 8, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 0, 0 ], + "pos": [ + 9, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 0, 0 ], + "pos": [ + 10, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 0, 0 ], + "pos": [ + 11, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 0, 0 ], + "pos": [ + 12, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 0, 0 ], + "pos": [ + 13, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 0, 0 ], + "pos": [ + 14, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 0, 0 ], + "pos": [ + 15, + 0, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 1, 0 ], + "pos": [ + 0, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 1, 0 ], + "pos": [ + 6, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 1, 0 ], + "pos": [ + 7, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 1, 0 ], + "pos": [ + 8, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 1, 0 ], + "pos": [ + 9, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 1, 0 ], + "pos": [ + 10, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 1, 0 ], + "pos": [ + 11, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 1, 0 ], + "pos": [ + 12, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 1, 0 ], + "pos": [ + 13, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 1, 0 ], + "pos": [ + 14, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 1, 0 ], + "pos": [ + 15, + 1, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 2, 0 ], + "pos": [ + 0, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 2, 0 ], + "pos": [ + 6, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 2, 0 ], + "pos": [ + 7, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 2, 0 ], + "pos": [ + 8, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 2, 0 ], + "pos": [ + 9, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 2, 0 ], + "pos": [ + 10, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 2, 0 ], + "pos": [ + 11, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 2, 0 ], + "pos": [ + 12, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 2, 0 ], + "pos": [ + 13, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 2, 0 ], + "pos": [ + 14, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 2, 0 ], + "pos": [ + 15, + 2, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 3, 0 ], + "pos": [ + 0, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 3, 0 ], + "pos": [ + 6, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 3, 0 ], + "pos": [ + 7, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 3, 0 ], + "pos": [ + 8, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 3, 0 ], + "pos": [ + 9, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 3, 0 ], + "pos": [ + 10, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 3, 0 ], + "pos": [ + 11, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 3, 0 ], + "pos": [ + 12, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 3, 0 ], + "pos": [ + 13, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 3, 0 ], + "pos": [ + 14, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 3, 0 ], + "pos": [ + 15, + 3, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 4, 0 ], + "pos": [ + 0, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 4, 0 ], + "pos": [ + 1, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 4, 0 ], + "pos": [ + 2, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 4, 0 ], + "pos": [ + 3, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 4, 0 ], + "pos": [ + 4, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 4, 0 ], + "pos": [ + 5, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 4, 0 ], + "pos": [ + 6, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 4, 0 ], + "pos": [ + 7, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 4, 0 ], + "pos": [ + 8, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 4, 0 ], + "pos": [ + 9, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 4, 0 ], + "pos": [ + 10, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 4, 0 ], + "pos": [ + 11, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 4, 0 ], + "pos": [ + 12, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 4, 0 ], + "pos": [ + 13, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 4, 0 ], + "pos": [ + 14, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 4, 0 ], + "pos": [ + 15, + 4, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 5, 0 ], + "pos": [ + 0, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 5, 0 ], + "pos": [ + 1, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 5, 0 ], + "pos": [ + 2, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 5, 0 ], + "pos": [ + 3, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 5, 0 ], + "pos": [ + 4, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 5, 0 ], + "pos": [ + 5, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 5, 0 ], + "pos": [ + 6, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 5, 0 ], + "pos": [ + 7, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 5, 0 ], + "pos": [ + 8, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 5, 0 ], + "pos": [ + 9, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 5, 0 ], + "pos": [ + 10, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 5, 0 ], + "pos": [ + 11, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 5, 0 ], + "pos": [ + 12, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 5, 0 ], + "pos": [ + 13, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 5, 0 ], + "pos": [ + 14, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 5, 0 ], + "pos": [ + 15, + 5, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 6, 0 ], + "pos": [ + 0, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 6, 0 ], + "pos": [ + 1, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 6, 0 ], + "pos": [ + 2, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 6, 0 ], + "pos": [ + 3, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 6, 0 ], + "pos": [ + 4, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 6, 0 ], + "pos": [ + 5, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 6, 0 ], + "pos": [ + 6, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 6, 0 ], + "pos": [ + 7, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 6, 0 ], + "pos": [ + 8, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 6, 0 ], + "pos": [ + 9, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 6, 0 ], + "pos": [ + 10, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 6, 0 ], + "pos": [ + 11, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 6, 0 ], + "pos": [ + 12, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 6, 0 ], + "pos": [ + 13, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 6, 0 ], + "pos": [ + 14, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 6, 0 ], + "pos": [ + 15, + 6, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 7, 0 ], + "pos": [ + 0, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 7, 0 ], + "pos": [ + 1, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 7, 0 ], + "pos": [ + 2, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 7, 0 ], + "pos": [ + 3, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 7, 0 ], + "pos": [ + 4, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 7, 0 ], + "pos": [ + 5, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 7, 0 ], + "pos": [ + 6, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 7, 0 ], + "pos": [ + 7, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 7, 0 ], + "pos": [ + 8, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 7, 0 ], + "pos": [ + 9, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 7, 0 ], + "pos": [ + 10, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 7, 0 ], + "pos": [ + 11, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 7, 0 ], + "pos": [ + 12, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 7, 0 ], + "pos": [ + 13, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 7, 0 ], + "pos": [ + 14, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 7, 0 ], + "pos": [ + 15, + 7, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 8, 0 ], + "pos": [ + 0, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 8, 0 ], + "pos": [ + 1, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 8, 0 ], + "pos": [ + 2, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 8, 0 ], + "pos": [ + 3, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 8, 0 ], + "pos": [ + 4, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 8, 0 ], + "pos": [ + 5, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 8, 0 ], + "pos": [ + 6, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 8, 0 ], + "pos": [ + 7, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 8, 0 ], + "pos": [ + 8, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 8, 0 ], + "pos": [ + 9, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 8, 0 ], + "pos": [ + 10, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 8, 0 ], + "pos": [ + 11, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 8, 0 ], + "pos": [ + 12, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 8, 0 ], + "pos": [ + 13, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 8, 0 ], + "pos": [ + 14, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 8, 0 ], + "pos": [ + 15, + 8, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 9, 0 ], + "pos": [ + 0, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 9, 0 ], + "pos": [ + 1, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 9, 0 ], + "pos": [ + 2, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 9, 0 ], + "pos": [ + 3, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 9, 0 ], + "pos": [ + 4, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 9, 0 ], + "pos": [ + 5, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 9, 0 ], + "pos": [ + 6, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 9, 0 ], + "pos": [ + 7, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 9, 0 ], + "pos": [ + 8, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 9, 0 ], + "pos": [ + 9, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 9, 0 ], + "pos": [ + 10, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 9, 0 ], + "pos": [ + 11, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 9, 0 ], + "pos": [ + 12, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 9, 0 ], + "pos": [ + 13, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 9, 0 ], + "pos": [ + 14, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 9, 0 ], + "pos": [ + 15, + 9, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 10, 0 ], + "pos": [ + 0, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 10, 0 ], + "pos": [ + 1, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 10, 0 ], + "pos": [ + 2, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 10, 0 ], + "pos": [ + 3, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 10, 0 ], + "pos": [ + 4, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 10, 0 ], + "pos": [ + 5, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 10, 0 ], + "pos": [ + 6, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 10, 0 ], + "pos": [ + 7, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 10, 0 ], + "pos": [ + 8, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 10, 0 ], + "pos": [ + 9, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 10, 0 ], + "pos": [ + 10, + 10, + 0 + ], "type": 6, "tile": 0 }, { - "pos": [ 11, 10, 0 ], + "pos": [ + 11, + 10, + 0 + ], "type": 2, "tile": 0 }, { - "pos": [ 12, 10, 0 ], + "pos": [ + 12, + 10, + 0 + ], "type": 7, "tile": 0 }, { - "pos": [ 13, 10, 0 ], + "pos": [ + 13, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 10, 0 ], + "pos": [ + 14, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 10, 0 ], + "pos": [ + 15, + 10, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 11, 0 ], + "pos": [ + 0, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 11, 0 ], + "pos": [ + 1, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 11, 0 ], + "pos": [ + 2, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 11, 0 ], + "pos": [ + 3, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 11, 0 ], + "pos": [ + 4, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 11, 0 ], + "pos": [ + 5, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 11, 0 ], + "pos": [ + 6, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 11, 0 ], + "pos": [ + 7, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 11, 0 ], + "pos": [ + 8, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 11, 0 ], + "pos": [ + 9, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 11, 0 ], + "pos": [ + 10, + 11, + 0 + ], "type": 3, "tile": 0 }, { - "pos": [ 11, 11, 1 ], - "type": 1, - "tile": 0 - }, - { - "pos": [ 12, 11, 0 ], + "pos": [ + 12, + 11, + 0 + ], "type": 5, "tile": 0 }, { - "pos": [ 13, 11, 0 ], + "pos": [ + 13, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 11, 0 ], + "pos": [ + 14, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 11, 0 ], + "pos": [ + 15, + 11, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 12, 0 ], + "pos": [ + 0, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 12, 0 ], + "pos": [ + 1, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 12, 0 ], + "pos": [ + 2, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 12, 0 ], + "pos": [ + 3, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 12, 0 ], + "pos": [ + 4, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 12, 0 ], + "pos": [ + 5, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 12, 0 ], + "pos": [ + 6, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 12, 0 ], + "pos": [ + 7, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 12, 0 ], + "pos": [ + 8, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 12, 0 ], + "pos": [ + 9, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 12, 0 ], + "pos": [ + 10, + 12, + 0 + ], "type": 8, "tile": 0 }, { - "pos": [ 11, 12, 0 ], + "pos": [ + 11, + 12, + 0 + ], "type": 4, "tile": 0 }, { - "pos": [ 12, 12, 0 ], + "pos": [ + 12, + 12, + 0 + ], "type": 9, "tile": 0 }, { - "pos": [ 13, 12, 0 ], + "pos": [ + 13, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 12, 0 ], + "pos": [ + 14, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 12, 0 ], + "pos": [ + 15, + 12, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 13, 0 ], + "pos": [ + 0, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 13, 0 ], + "pos": [ + 1, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 13, 0 ], + "pos": [ + 2, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 13, 0 ], + "pos": [ + 3, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 13, 0 ], + "pos": [ + 4, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 13, 0 ], + "pos": [ + 5, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 13, 0 ], + "pos": [ + 6, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 13, 0 ], + "pos": [ + 7, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 13, 0 ], + "pos": [ + 8, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 13, 0 ], + "pos": [ + 9, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 13, 0 ], + "pos": [ + 10, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 13, 0 ], + "pos": [ + 11, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 13, 0 ], + "pos": [ + 12, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 13, 0 ], + "pos": [ + 13, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 13, 0 ], + "pos": [ + 14, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 13, 0 ], + "pos": [ + 15, + 13, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 14, 0 ], + "pos": [ + 0, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 14, 0 ], + "pos": [ + 1, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 14, 0 ], + "pos": [ + 2, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 14, 0 ], + "pos": [ + 3, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 14, 0 ], + "pos": [ + 4, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 14, 0 ], + "pos": [ + 5, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 14, 0 ], + "pos": [ + 6, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 14, 0 ], + "pos": [ + 7, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 14, 0 ], + "pos": [ + 8, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 14, 0 ], + "pos": [ + 9, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 14, 0 ], + "pos": [ + 10, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 14, 0 ], + "pos": [ + 11, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 14, 0 ], + "pos": [ + 12, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 14, 0 ], + "pos": [ + 13, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 14, 0 ], + "pos": [ + 14, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 14, 0 ], + "pos": [ + 15, + 14, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 0, 15, 0 ], + "pos": [ + 0, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 1, 15, 0 ], + "pos": [ + 1, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 2, 15, 0 ], + "pos": [ + 2, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 3, 15, 0 ], + "pos": [ + 3, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 4, 15, 0 ], + "pos": [ + 4, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 5, 15, 0 ], + "pos": [ + 5, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 6, 15, 0 ], + "pos": [ + 6, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 7, 15, 0 ], + "pos": [ + 7, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 8, 15, 0 ], + "pos": [ + 8, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 9, 15, 0 ], + "pos": [ + 9, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 10, 15, 0 ], + "pos": [ + 10, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 11, 15, 0 ], + "pos": [ + 11, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 12, 15, 0 ], + "pos": [ + 12, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 13, 15, 0 ], + "pos": [ + 13, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 14, 15, 0 ], + "pos": [ + 14, + 15, + 0 + ], "type": 1, "tile": 0 }, { - "pos": [ 15, 15, 0 ], + "pos": [ + 15, + 15, + 0 + ], + "type": 1, + "tile": 0 + }, + { + "pos": [ + 11, + 11, + 1 + ], "type": 1, "tile": 0 } + ], + "meshes": [ + { + "file": "house_5_3.dmf", + "pos": [ + 1, + 1, + 0 + ] + } ] -} +} \ No newline at end of file diff --git a/editor/client/public/common/chunkterrain.js b/editor/client/public/common/chunkterrain.js new file mode 100644 index 00000000..663ab029 --- /dev/null +++ b/editor/client/public/common/chunkterrain.js @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +"use strict"; + +// ChunkTerrain - JS port of the terrain-quad generation performed by +// tools/asset/chunk/__main__.py (build_terrain_verts / _tile_quad / +// _RAMP_CORNERS) so the editor's 3D preview can show the same geometry the +// Python compiler will bake into the chunk's terrain mesh, without waiting +// for a compile pass. Vertex layout matches DMF: interleaved [u, v, x, y, z]. + +const ChunkTerrain = (() => { + const CHUNK_WIDTH = 16; + const CHUNK_HEIGHT = 16; + const CHUNK_DEPTH = 4; + const TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH; + + const TILE_SHAPE_NULL = 0; + const TILE_SHAPE_GROUND = 1; + const TILE_SHAPE_RAMP_NORTH = 2; + const TILE_SHAPE_RAMP_EAST = 3; + const TILE_SHAPE_RAMP_SOUTH = 4; + const TILE_SHAPE_RAMP_WEST = 5; + const TILE_SHAPE_RAMP_NORTHEAST = 6; + const TILE_SHAPE_RAMP_NORTHWEST = 7; + const TILE_SHAPE_RAMP_SOUTHEAST = 8; + const TILE_SHAPE_RAMP_SOUTHWEST = 9; + + // Per-shape corner heights as [sw, se, ne, nw] offsets from tile base Z. + // NORTH=+Y, EAST=+X. Cardinal ramps: low face at 0, high face at 1. + // Diagonal ramps: opposite corner at 0/1, adjacent corners at 0.5. + const RAMP_CORNERS = { + [TILE_SHAPE_GROUND]: [0.0, 0.0, 0.0, 0.0], + [TILE_SHAPE_RAMP_NORTH]: [0.0, 0.0, 1.0, 1.0], + [TILE_SHAPE_RAMP_SOUTH]: [1.0, 1.0, 0.0, 0.0], + [TILE_SHAPE_RAMP_EAST]: [0.0, 1.0, 1.0, 0.0], + [TILE_SHAPE_RAMP_WEST]: [1.0, 0.0, 0.0, 1.0], + [TILE_SHAPE_RAMP_NORTHEAST]: [0.0, 0.0, 1.0, 0.0], + [TILE_SHAPE_RAMP_NORTHWEST]: [0.0, 0.0, 0.0, 1.0], + [TILE_SHAPE_RAMP_SOUTHEAST]: [0.0, 1.0, 0.0, 0.0], + [TILE_SHAPE_RAMP_SOUTHWEST]: [1.0, 0.0, 0.0, 0.0], + }; + + function tileIndex(x, y, z) { + return x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT; + } + + // Six vertices (2 CCW triangles) for a quad with per-corner Z heights. + // Corner order: SW=(fx,fy), SE=(fx+1,fy), NE=(fx+1,fy+1), NW=(fx,fy+1). + function pushTileQuad(out, u0, u1, v0, v1, fx, fy, swZ, seZ, neZ, nwZ) { + out.push( + u0, v0, fx, fy, swZ, + u1, v0, fx + 1, fy, seZ, + u1, v1, fx + 1, fy + 1, neZ, + u0, v0, fx, fy, swZ, + u1, v1, fx + 1, fy + 1, neZ, + u0, v1, fx, fy + 1, nwZ, + ); + } + + // Generate terrain quads for every non-null tile in a flat tiles array + // (length TILE_COUNT, indexed via tileIndex). Returns a Float32Array of + // interleaved [u, v, x, y, z] vertices, matching DMF.VERTEX_FLOATS. + function buildTerrainVerts(tiles) { + const out = []; + for(let z = 0; z < CHUNK_DEPTH; z++) { + for(let y = 0; y < CHUNK_HEIGHT; y++) { + for(let x = 0; x < CHUNK_WIDTH; x++) { + const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL; + const corners = RAMP_CORNERS[type]; + if(!corners) continue; + + const u0 = x / CHUNK_WIDTH; + const u1 = (x + 1) / CHUNK_WIDTH; + const v0 = y / CHUNK_HEIGHT; + const v1 = (y + 1) / CHUNK_HEIGHT; + const [sw, se, ne, nw] = corners; + + pushTileQuad(out, u0, u1, v0, v1, x, y, z + sw, z + se, z + ne, z + nw); + } + } + } + return new Float32Array(out); + } + + return Object.freeze({ + CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT, + TILE_SHAPE_NULL, TILE_SHAPE_GROUND, + TILE_SHAPE_RAMP_NORTH, TILE_SHAPE_RAMP_EAST, + TILE_SHAPE_RAMP_SOUTH, TILE_SHAPE_RAMP_WEST, + TILE_SHAPE_RAMP_NORTHEAST, TILE_SHAPE_RAMP_NORTHWEST, + TILE_SHAPE_RAMP_SOUTHEAST, TILE_SHAPE_RAMP_SOUTHWEST, + RAMP_CORNERS, tileIndex, buildTerrainVerts, + }); +})(); diff --git a/editor/client/public/common/dmf.js b/editor/client/public/common/dmf.js new file mode 100644 index 00000000..2a293cb5 --- /dev/null +++ b/editor/client/public/common/dmf.js @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +"use strict"; + +// DMF - Dusk Mesh Format +// +// Header (12 bytes): +// [0-3] "DMF\0" magic +// [4-7] uint32 version (little-endian) +// [8-11] uint32 vertCount (little-endian) +// +// Followed by vertCount vertices, each 20 bytes: +// [0-7] float32[2] uv (little-endian) +// [8-19] float32[3] pos (little-endian) + +const DMF = (() => { + const MAGIC = [0x44, 0x4d, 0x46, 0x00]; // "DMF\0" + const VERSION = 1; + const HEADER_SIZE = 12; + const VERTEX_FLOATS = 5; // uv[2] + pos[3] + const VERTEX_SIZE = VERTEX_FLOATS * 4; + + // Decode a DMF ArrayBuffer into { version, vertexCount, floats }, where + // floats is a Float32Array of tightly interleaved [u, v, x, y, z] per + // vertex - ready to hand straight to gl.bufferData(). + function decode(buffer) { + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + + if(bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DMF"); + if( + bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || + bytes[2] !== MAGIC[2] || bytes[3] !== MAGIC[3] + ) { + throw new Error("Invalid DMF magic bytes - not a DMF file"); + } + + const version = view.getUint32(4, true); + if(version !== VERSION) { + throw new Error(`Unsupported DMF version: ${version}`); + } + + const vertexCount = view.getUint32(8, true); + const expected = HEADER_SIZE + vertexCount * VERTEX_SIZE; + if(bytes.length < expected) { + throw new Error(`DMF vertex data truncated (expected ${expected} bytes, got ${bytes.length})`); + } + + const floats = new Float32Array(vertexCount * VERTEX_FLOATS); + for(let i = 0; i < floats.length; i++) { + floats[i] = view.getFloat32(HEADER_SIZE + i * 4, true); + } + + return { version, vertexCount, floats }; + } + + return Object.freeze({ + decode, + VERSION, HEADER_SIZE, VERTEX_FLOATS, VERTEX_SIZE, + }); +})(); diff --git a/editor/client/public/index.html b/editor/client/public/index.html index 3507e45b..2eb5fe25 100644 --- a/editor/client/public/index.html +++ b/editor/client/public/index.html @@ -1 +1,6 @@ -

Hello Homepage

+
+
+

Dusk Map Editor

+

Browse and edit project assets.

+
+
diff --git a/editor/client/public/map/index.html b/editor/client/public/map/index.html new file mode 100644 index 00000000..093d8e86 --- /dev/null +++ b/editor/client/public/map/index.html @@ -0,0 +1,91 @@ + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + + + +
+ + + + +
+
+ + +
+ + +
+
 
+
+ +
+
+ + + + + 0 +
+
+
+ +
+ +
Meshes
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Preview (scroll to zoom)

+ +
+
+ + + + + diff --git a/editor/client/public/map/map.css b/editor/client/public/map/map.css new file mode 100644 index 00000000..e4b3b054 --- /dev/null +++ b/editor/client/public/map/map.css @@ -0,0 +1,65 @@ +/* Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +.tile-palette { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 6px; + max-height: 512px; +} + +.tile-swatch { + position: relative; + width: 32px; + height: 32px; + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; + padding: 0; +} + +.tile-swatch.active { + border-color: #fff; +} + +.tile-swatch-icon { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.dpad { + display: grid; + grid-template-columns: repeat(3, 2rem); + grid-template-rows: repeat(3, 2rem); + grid-template-areas: + ". n ." + "w . e" + ". s ."; + gap: 2px; +} + +.dpad-n { grid-area: n; } +.dpad-w { grid-area: w; } +.dpad-e { grid-area: e; } +.dpad-s { grid-area: s; } +.dpad button { padding: 0; } + +.tile-grid { + image-rendering: pixelated; + cursor: crosshair; + touch-action: none; + max-width: 100%; + height: auto; +} + +#previewCanvas { + width: 100%; + aspect-ratio: 1 / 1; + height: auto; +} diff --git a/editor/client/public/map/map.js b/editor/client/public/map/map.js new file mode 100644 index 00000000..52adedb8 --- /dev/null +++ b/editor/client/public/map/map.js @@ -0,0 +1,701 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +"use strict"; + +(() => { + const SHAPES = [ + { type: ChunkTerrain.TILE_SHAPE_NULL, name: "Erase", color: "#20232a" }, + { type: ChunkTerrain.TILE_SHAPE_GROUND, name: "Ground", color: "#4caf50" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTH, name: "Ramp North", color: "#2196f3" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_EAST, name: "Ramp East", color: "#03a9f4" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTH, name: "Ramp South", color: "#00bcd4" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_WEST, name: "Ramp West", color: "#009688" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST, name: "Ramp NE", color: "#ff9800" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST, name: "Ramp NW", color: "#ff5722" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST, name: "Ramp SE", color: "#9c27b0" }, + { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST, name: "Ramp SW", color: "#e91e63" }, + ]; + const SHAPE_COLORS = Object.fromEntries(SHAPES.map((s) => [s.type, s.color])); + + // Arrow direction per ramp, in canvas/screen space: north is up (dy < 0), + // east is right (dx > 0) - matches renderGrid()'s north-is-up convention. + const SHAPE_DIRECTIONS = { + [ChunkTerrain.TILE_SHAPE_RAMP_NORTH]: { dx: 0, dy: -1 }, + [ChunkTerrain.TILE_SHAPE_RAMP_SOUTH]: { dx: 0, dy: 1 }, + [ChunkTerrain.TILE_SHAPE_RAMP_EAST]: { dx: 1, dy: 0 }, + [ChunkTerrain.TILE_SHAPE_RAMP_WEST]: { dx: -1, dy: 0 }, + [ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST]: { dx: 1, dy: -1 }, + [ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST]: { dx: -1, dy: -1 }, + [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST]: { dx: 1, dy: 1 }, + [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST]: { dx: -1, dy: 1 }, + }; + + // Draws an arrow centered at (cx, cy) pointing toward the ramp's high + // side, so ramp direction is visible at a glance on both the palette + // swatches and the tile grid. + function drawArrow(ctx, cx, cy, size, dx, dy) { + const mag = Math.hypot(dx, dy) || 1; + const ux = dx / mag, uy = dy / mag; + const len = size * 0.32; + const tipX = cx + ux * len, tipY = cy + uy * len; + const tailX = cx - ux * len, tailY = cy - uy * len; + + ctx.strokeStyle = "#ffffff"; + ctx.fillStyle = "#ffffff"; + ctx.lineWidth = Math.max(1, size * 0.08); + ctx.lineCap = "round"; + + ctx.beginPath(); + ctx.moveTo(tailX, tailY); + ctx.lineTo(tipX, tipY); + ctx.stroke(); + + const headLen = size * 0.18; + const angle = Math.atan2(uy, ux); + const leftAngle = angle + Math.PI * 0.8; + const rightAngle = angle - Math.PI * 0.8; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo(tipX + Math.cos(leftAngle) * headLen, tipY + Math.sin(leftAngle) * headLen); + ctx.lineTo(tipX + Math.cos(rightAngle) * headLen, tipY + Math.sin(rightAngle) * headLen); + ctx.closePath(); + ctx.fill(); + } + + let coord = { x: 0, y: 0, z: 0 }; + let tiles = new Int32Array(ChunkTerrain.TILE_COUNT); + let meshes = []; + let chunkExists = false; + let currentZLevel = 0; + let selectedShape = ChunkTerrain.TILE_SHAPE_GROUND; + let zoomWorldH = 20; + let dirty = false; + let hoverTile = null; + + let mapRenderer = null; + let terrainMesh = null; + let terrainTexturePromise = null; + let modelIndex = null; + let neighborChunks = []; + const modelCache = new Map(); + + const UNDO_LIMIT = 100; + let undoStack = []; + + function markDirty() { + dirty = true; + } + + function snapshotState() { + return { + tiles: tiles.slice(), + meshes: meshes.map((m) => ({ file: m.file, pos: m.pos.slice() })), + }; + } + + function pushUndo() { + undoStack.push(snapshotState()); + if(undoStack.length > UNDO_LIMIT) undoStack.shift(); + } + + function undo() { + if(!undoStack.length) return; + const snap = undoStack.pop(); + tiles = snap.tiles; + meshes = snap.meshes; + dirty = true; + renderGrid(); + renderMeshList(); + renderPreview(); + updateStatus("Undid last change."); + } + + // Returns true if it's OK to proceed (no unsaved changes, or the user + // confirmed discarding them). + function confirmDiscardIfDirty() { + if(!dirty) return true; + return window.confirm("You have unsaved changes to this chunk. Discard them?"); + } + + function statusLine() { + return document.getElementById("statusLine"); + } + + function updateStatus(msg, isError) { + const el = statusLine(); + el.textContent = msg; + el.classList.toggle("text-danger", !!isError); + el.classList.toggle("text-muted", !isError); + } + + async function apiReadJson(path) { + const res = await fetch(`/api/read?path=${encodeURIComponent(path)}`); + const data = await res.json(); + return { ok: res.ok, data }; + } + + async function apiWrite(path, content) { + return fetch(`/api/write?path=${encodeURIComponent(path)}`, { + method: "POST", + body: JSON.stringify({ content }), + }); + } + + async function apiDelete(path) { + return fetch(`/api/delete?path=${encodeURIComponent(path)}`, { method: "POST" }); + } + + async function apiCompileChunk(x, y, z) { + const res = await fetch(`/api/compile-chunk?x=${x}&y=${y}&z=${z}`, { method: "POST" }); + return res.json(); + } + + async function apiLs(path) { + const res = await fetch(`/api/ls?path=${encodeURIComponent(path)}`); + return res.json(); + } + + async function apiFind(path, ext) { + const res = await fetch(`/api/find?path=${encodeURIComponent(path)}&ext=${encodeURIComponent(ext)}`); + return res.json(); + } + + function base64ToBuffer(base64) { + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + return bytes.buffer; + } + + async function loadImageTexture(assetPath) { + const { ok, data } = await apiReadJson(assetPath); + if(!ok || data.error) return null; + const blob = new Blob([base64ToBuffer(data.content)], { type: "image/png" }); + const bitmap = await createImageBitmap(blob); + return mapRenderer.createTextureFromImage(bitmap); + } + + async function readDmfVertices(assetPath) { + const { ok, data } = await apiReadJson(assetPath); + if(!ok || data.error) throw new Error(`Failed to read ${assetPath}`); + return DMF.decode(base64ToBuffer(data.content)).floats; + } + + async function ensureModelIndex() { + if(modelIndex) return modelIndex; + const res = await apiFind("assets/models", ".json"); + modelIndex = new Map(); + for(const file of res.files || []) { + const base = file.split("/").pop().replace(/\.json$/, ""); + modelIndex.set(base, file); + } + return modelIndex; + } + + // Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to + // its model JSON by basename, mirroring find_model() in + // tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare + // filename, not a path. + async function resolveModelForMeshFile(file) { + await ensureModelIndex(); + const base = file.replace(/\.[^.]+$/, ""); + const rel = modelIndex.get(base); + if(!rel) return null; + + const modelPath = `assets/models/${rel}`; + if(modelCache.has(modelPath)) return modelCache.get(modelPath); + + const promise = (async () => { + const { ok, data } = await apiReadJson(modelPath); + if(!ok || data.error) throw new Error(`Failed to read model ${modelPath}`); + const modelDef = JSON.parse(data.content); + const meshFloats = await readDmfVertices(`assets/${modelDef.mesh}`); + const mesh = mapRenderer.createMesh(meshFloats); + const texture = modelDef.texture ? await loadImageTexture(`assets/${modelDef.texture}`) : null; + const colorBytes = modelDef.color || [255, 255, 255, 255]; + const color = colorBytes.map((c) => c / 255); + return { mesh, texture, color }; + })(); + modelCache.set(modelPath, promise); + return promise; + } + + function chunkPaths(x, y, z) { + return { + raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`, + dcf: `assets/chunks/${x}_${y}_${z}.dcf`, + terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`, + terrainModel: `assets/models/chunks/chunk_${x}_${y}_${z}_0.json`, + }; + } + + // Loads the 8 same-Z neighbors around `coord` (read-only, for context in + // the 3D preview) and pre-builds their render data so renderPreview() + // doesn't have to rebuild GL buffers on every paint stroke. + async function loadNeighborChunks() { + const offsets = []; + for(let dy = -1; dy <= 1; dy++) { + for(let dx = -1; dx <= 1; dx++) { + if(dx || dy) offsets.push({ dx, dy }); + } + } + + const results = await Promise.all(offsets.map(async ({ dx, dy }) => { + const nx = coord.x + dx, ny = coord.y + dy, nz = coord.z; + const { ok, data } = await apiReadJson(chunkPaths(nx, ny, nz).raw); + if(!ok || data.error) return null; + + const json = JSON.parse(data.content); + const neighborTiles = new Int32Array(ChunkTerrain.TILE_COUNT); + for(const t of json.tiles || []) { + const [tx, ty, tz] = t.pos; + neighborTiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type; + } + const neighborMeshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] })); + + const floats = ChunkTerrain.buildTerrainVerts(neighborTiles); + const terrain = floats.length ? { mesh: mapRenderer.createMesh(floats) } : null; + + const models = []; + for(const m of neighborMeshes) { + try { + const resolved = await resolveModelForMeshFile(m.file); + if(resolved) models.push({ ...resolved, offset: m.pos }); + } catch(e) { + console.warn("Failed to load neighbor mesh preview", m.file, e); + } + } + + return { + offset: [dx * ChunkTerrain.CHUNK_WIDTH, dy * ChunkTerrain.CHUNK_HEIGHT, 0], + terrain, + models, + }; + })); + + neighborChunks = results.filter(Boolean); + } + + async function loadChunk(x, y, z) { + coord = { x, y, z }; + tiles = new Int32Array(ChunkTerrain.TILE_COUNT); + meshes = []; + chunkExists = false; + + const { ok, data } = await apiReadJson(chunkPaths(x, y, z).raw); + if(ok && !data.error) { + chunkExists = true; + const json = JSON.parse(data.content); + for(const t of json.tiles || []) { + const [tx, ty, tz] = t.pos; + tiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type; + } + meshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] })); + } + + await loadNeighborChunks(); + dirty = false; + undoStack = []; + + renderGrid(); + renderMeshList(); + renderPreview(); + updateStatus(chunkExists + ? `Loaded chunk_${x}_${y}_${z}.json` + : `New (empty) chunk at ${x}, ${y}, ${z} - paint a tile and save to create it.`); + } + + async function saveChunk() { + const tilesArr = []; + for(let z = 0; z < ChunkTerrain.CHUNK_DEPTH; z++) { + for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) { + for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) { + const type = tiles[ChunkTerrain.tileIndex(x, y, z)]; + if(type && type !== ChunkTerrain.TILE_SHAPE_NULL) { + tilesArr.push({ pos: [x, y, z], type, tile: 0 }); + } + } + } + } + + const paths = chunkPaths(coord.x, coord.y, coord.z); + + if(tilesArr.length === 0 && meshes.length === 0) { + await Promise.all(Object.values(paths).map((p) => apiDelete(p))); + chunkExists = false; + dirty = false; + updateStatus(`Chunk ${coord.x}, ${coord.y}, ${coord.z} is empty - deleted.`); + return; + } + + const json = { tiles: tilesArr, meshes: meshes.map((m) => ({ file: m.file, pos: m.pos })) }; + await apiWrite(paths.raw, JSON.stringify(json, null, 2)); + chunkExists = true; + dirty = false; + updateStatus("Saved. Compiling..."); + + const result = await apiCompileChunk(coord.x, coord.y, coord.z); + if(result.ok) { + updateStatus(`Compiled OK.\n${result.stdout}`); + } else { + updateStatus(`Compile FAILED (code ${result.returncode}):\n${result.stderr}`, true); + } + } + + function buildPalette() { + const el = document.getElementById("tilePalette"); + el.innerHTML = ""; + for(const s of SHAPES) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "tile-swatch" + (s.type === selectedShape ? " active" : ""); + btn.style.background = s.color; + btn.title = s.name; + btn.addEventListener("click", () => { + selectedShape = s.type; + buildPalette(); + }); + + const dir = SHAPE_DIRECTIONS[s.type]; + if(dir) { + const icon = document.createElement("canvas"); + icon.width = 32; + icon.height = 32; + icon.className = "tile-swatch-icon"; + drawArrow(icon.getContext("2d"), 16, 16, 32, dir.dx, dir.dy); + btn.appendChild(icon); + } + + el.appendChild(btn); + } + } + + function canvasToTile(e, canvas) { + const rect = canvas.getBoundingClientRect(); + const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH; + const px = (e.clientX - rect.left) * (canvas.width / rect.width); + const py = (e.clientY - rect.top) * (canvas.height / rect.height); + const x = Math.floor(px / cell); + const yFromTop = Math.floor(py / cell); + const y = ChunkTerrain.CHUNK_HEIGHT - 1 - yFromTop; + if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) return null; + return { x, y }; + } + + function renderGrid() { + const canvas = document.getElementById("gridCanvas"); + const ctx = canvas.getContext("2d"); + const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) { + for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) { + const type = tiles[ChunkTerrain.tileIndex(x, y, currentZLevel)]; + const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell; + ctx.fillStyle = SHAPE_COLORS[type] || "#15171b"; + ctx.fillRect(x * cell, sy, cell, cell); + ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; + ctx.strokeRect(x * cell, sy, cell, cell); + + const dir = SHAPE_DIRECTIONS[type]; + if(dir) drawArrow(ctx, x * cell + cell / 2, sy + cell / 2, cell, dir.dx, dir.dy); + } + } + + ctx.fillStyle = "#ffffff"; + for(const m of meshes) { + const [mx, my, mz] = m.pos; + if(Math.round(mz) !== currentZLevel) continue; + const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell; + ctx.beginPath(); + ctx.arc(mx * cell + cell / 2, sy + cell / 2, cell * 0.3, 0, Math.PI * 2); + ctx.fill(); + } + + if(hoverTile) { + const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - hoverTile.y) * cell; + ctx.strokeStyle = "#ffeb3b"; + ctx.lineWidth = 2; + ctx.strokeRect(hoverTile.x * cell + 1, sy + 1, cell - 2, cell - 2); + } + } + + function hitTestMesh(e, canvas) { + const rect = canvas.getBoundingClientRect(); + const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH; + const px = (e.clientX - rect.left) * (canvas.width / rect.width); + const py = (e.clientY - rect.top) * (canvas.height / rect.height); + + let closest = -1; + let closestDist = cell * 0.5; + meshes.forEach((m, i) => { + const [mx, my, mz] = m.pos; + if(Math.round(mz) !== currentZLevel) return; + const cx = mx * cell + cell / 2; + const cy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell + cell / 2; + const dist = Math.hypot(px - cx, py - cy); + if(dist < closestDist) { closestDist = dist; closest = i; } + }); + return closest; + } + + function moveMeshTo(e, index) { + const canvas = document.getElementById("gridCanvas"); + const t = canvasToTile(e, canvas); + if(!t) return; + meshes[index].pos[0] = t.x; + meshes[index].pos[1] = t.y; + markDirty(); + renderGrid(); + renderMeshList(); + renderPreview(); + } + + function paintAt(e) { + const canvas = document.getElementById("gridCanvas"); + const t = canvasToTile(e, canvas); + if(!t) return; + tiles[ChunkTerrain.tileIndex(t.x, t.y, currentZLevel)] = selectedShape; + markDirty(); + renderGrid(); + renderPreview(); + } + + function rebuildTerrainMesh() { + const floats = ChunkTerrain.buildTerrainVerts(tiles); + terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null; + } + + async function renderPreview() { + if(!mapRenderer) return; + rebuildTerrainMesh(); + + const models = []; + for(const m of meshes) { + try { + const resolved = await resolveModelForMeshFile(m.file); + if(resolved) models.push({ ...resolved, offset: m.pos }); + } catch(e) { + console.warn("Failed to load mesh preview", m.file, e); + } + } + + let terrainTexture = null; + if(terrainMesh || neighborChunks.some((n) => n.terrain)) { + if(!terrainTexturePromise) terrainTexturePromise = loadImageTexture("assets/tiles.png"); + terrainTexture = await terrainTexturePromise; + } + + const neighbors = neighborChunks.map((n) => ({ + offset: n.offset, + terrain: n.terrain ? { mesh: n.terrain.mesh, texture: terrainTexture } : null, + models: n.models, + })); + + mapRenderer.render({ + target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, currentZLevel], + worldH: zoomWorldH, + terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null, + models, + neighbors, + highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: currentZLevel } : null, + }); + } + + function renderMeshList() { + const ul = document.getElementById("meshList"); + ul.innerHTML = ""; + meshes.forEach((m, i) => { + const li = document.createElement("li"); + li.className = "list-group-item d-flex align-items-center gap-2 flex-wrap"; + + const label = document.createElement("span"); + label.className = "me-auto"; + label.textContent = m.file; + li.appendChild(label); + + ["X", "Y", "Z"].forEach((axis, axisIndex) => { + const input = document.createElement("input"); + input.type = "number"; + input.className = "form-control form-control-sm"; + input.style.width = "5rem"; + input.title = axis; + input.value = m.pos[axisIndex]; + input.addEventListener("change", () => { + pushUndo(); + m.pos[axisIndex] = parseFloat(input.value) || 0; + markDirty(); + renderGrid(); + renderPreview(); + }); + li.appendChild(input); + }); + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-sm btn-outline-danger"; + btn.textContent = "Remove"; + btn.addEventListener("click", () => { + pushUndo(); + meshes.splice(i, 1); + markDirty(); + renderMeshList(); + renderGrid(); + renderPreview(); + }); + + li.appendChild(btn); + ul.appendChild(li); + }); + } + + async function buildMeshPicker() { + await ensureModelIndex(); + const select = document.getElementById("meshPicker"); + select.innerHTML = ""; + for(const [base, rel] of [...modelIndex.entries()].sort()) { + const opt = document.createElement("option"); + opt.value = base; + opt.textContent = rel; + select.appendChild(opt); + } + } + + async function buildChunkBrowseList() { + const list = document.getElementById("chunkBrowseList"); + list.innerHTML = ""; + const res = await apiLs("assetsraw/chunks"); + for(const entry of res.entries || []) { + const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/); + if(!m) continue; + const [, x, y, z] = m; + + const li = document.createElement("li"); + const a = document.createElement("a"); + a.className = "dropdown-item"; + a.href = "#"; + a.textContent = `${x}, ${y}, ${z}`; + a.addEventListener("click", (e) => { + e.preventDefault(); + goToChunk(parseInt(x, 10), parseInt(y, 10), parseInt(z, 10)); + }); + + li.appendChild(a); + list.appendChild(li); + } + } + + function setCoordInputs(x, y, z) { + document.getElementById("coordX").value = x; + document.getElementById("coordY").value = y; + document.getElementById("coordZ").value = z; + } + + function goToChunk(x, y, z) { + if(!confirmDiscardIfDirty()) return; + setCoordInputs(x, y, z); + loadChunk(x, y, z); + } + + function bindEvents() { + const gridCanvas = document.getElementById("gridCanvas"); + let painting = false; + let draggingMeshIndex = -1; + + gridCanvas.addEventListener("mousedown", (e) => { + const hit = hitTestMesh(e, gridCanvas); + pushUndo(); + if(hit !== -1) { + draggingMeshIndex = hit; + return; + } + painting = true; + paintAt(e); + }); + window.addEventListener("mouseup", () => { painting = false; draggingMeshIndex = -1; }); + gridCanvas.addEventListener("mousemove", (e) => { + hoverTile = canvasToTile(e, gridCanvas); + if(draggingMeshIndex !== -1) { moveMeshTo(e, draggingMeshIndex); return; } + if(painting) { paintAt(e); return; } + renderGrid(); + renderPreview(); + }); + gridCanvas.addEventListener("mouseleave", () => { + hoverTile = null; + renderGrid(); + renderPreview(); + }); + + function setZLevel(z) { + currentZLevel = Math.max(0, Math.min(ChunkTerrain.CHUNK_DEPTH - 1, z)); + document.getElementById("zLevel").value = currentZLevel; + document.getElementById("zLevelLabel").textContent = currentZLevel; + renderGrid(); + renderPreview(); + } + + document.getElementById("zLevel").addEventListener("input", (e) => { + setZLevel(parseInt(e.target.value, 10)); + }); + document.getElementById("zLevelDown").addEventListener("click", () => setZLevel(currentZLevel - 1)); + document.getElementById("zLevelUp").addEventListener("click", () => setZLevel(currentZLevel + 1)); + + document.getElementById("btnLoad").addEventListener("click", () => { + const x = parseInt(document.getElementById("coordX").value, 10) || 0; + const y = parseInt(document.getElementById("coordY").value, 10) || 0; + const z = parseInt(document.getElementById("coordZ").value, 10) || 0; + goToChunk(x, y, z); + }); + + // NORTH=+Y, EAST=+X (matches tile-shape convention used by the tile + // grid and tools/asset/chunk/__main__.py). + document.getElementById("navN").addEventListener("click", () => goToChunk(coord.x, coord.y + 1, coord.z)); + document.getElementById("navS").addEventListener("click", () => goToChunk(coord.x, coord.y - 1, coord.z)); + document.getElementById("navE").addEventListener("click", () => goToChunk(coord.x + 1, coord.y, coord.z)); + document.getElementById("navW").addEventListener("click", () => goToChunk(coord.x - 1, coord.y, coord.z)); + document.getElementById("navUp").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z + 1)); + document.getElementById("navDown").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z - 1)); + + document.getElementById("btnSave").addEventListener("click", () => { + saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true)); + }); + + document.getElementById("btnAddMesh").addEventListener("click", () => { + const base = document.getElementById("meshPicker").value; + if(!base) return; + const pos = [ + parseFloat(document.getElementById("meshX").value) || 0, + parseFloat(document.getElementById("meshY").value) || 0, + parseFloat(document.getElementById("meshZ").value) || 0, + ]; + pushUndo(); + meshes.push({ file: `${base}.dmf`, pos }); + markDirty(); + renderMeshList(); + renderGrid(); + renderPreview(); + }); + + document.getElementById("previewCanvas").addEventListener("wheel", (e) => { + e.preventDefault(); + zoomWorldH = Math.min(60, Math.max(4, zoomWorldH + Math.sign(e.deltaY))); + renderPreview(); + }, { passive: false }); + + window.addEventListener("keydown", (e) => { + if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + e.preventDefault(); + undo(); + } + }); + } + + document.addEventListener("DOMContentLoaded", async () => { + buildPalette(); + mapRenderer = MapRenderer.create(document.getElementById("previewCanvas")); + bindEvents(); + await Promise.all([buildChunkBrowseList(), buildMeshPicker()]); + await loadChunk(coord.x, coord.y, coord.z); + }); +})(); diff --git a/editor/client/public/map/renderer.js b/editor/client/public/map/renderer.js new file mode 100644 index 00000000..0c5976a7 --- /dev/null +++ b/editor/client/public/map/renderer.js @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +"use strict"; + +// MapRenderer - a minimal, dependency-free WebGL renderer for the chunk +// preview pane. The camera formula is ported 1:1 from +// src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview +// approximates the game's own fixed-angle 3rd-person camera: the eye sits +// worldH units "behind" the target along Y and a proportional distance +// "above" along Z, always looking at the target, up = (0, 1, 0). +// +// The material model mirrors SHADER_UNLIT: a texture sampler multiplied by +// a flat RGBA tint, with a 1x1 white fallback texture standing in for +// untextured models. + +const MapRenderer = (() => { + const VERT_SRC = ` + attribute vec2 aUV; + attribute vec3 aPos; + uniform mat4 uModel; + uniform mat4 uView; + uniform mat4 uProjection; + varying vec2 vUV; + void main() { + vUV = aUV; + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); + } + `; + + const FRAG_SRC = ` + precision mediump float; + varying vec2 vUV; + uniform sampler2D uTexture; + uniform vec4 uColor; + void main() { + gl_FragColor = texture2D(uTexture, vUV) * uColor; + } + `; + + function mat4Identity(out) { + out.fill(0); + out[0] = out[5] = out[10] = out[15] = 1; + return out; + } + + function mat4Translation(out, v) { + mat4Identity(out); + out[12] = v[0]; + out[13] = v[1]; + out[14] = v[2]; + return out; + } + + function mat4Perspective(out, fovy, aspect, near, far) { + const f = 1.0 / Math.tan(fovy / 2); + out.fill(0); + out[0] = f / aspect; + out[5] = f; + out[10] = (far + near) / (near - far); + out[11] = -1; + out[14] = (2 * far * near) / (near - far); + return out; + } + + function vec3Sub(a, b) { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + } + + function vec3Cross(a, b) { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; + } + + function vec3Normalize(a) { + const len = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / len, a[1] / len, a[2] / len]; + } + + function mat4LookAt(out, eye, center, up) { + const zAxis = vec3Normalize(vec3Sub(eye, center)); + const xAxis = vec3Normalize(vec3Cross(up, zAxis)); + const yAxis = vec3Cross(zAxis, xAxis); + + out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0; + out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0; + out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0; + out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]); + out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]); + out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]); + out[15] = 1; + return out; + } + + // Ported from sceneoverworld.c:62-81. Returns { eye, view }. + function computeCamera(target, worldH) { + const fov = Math.PI / 4; // glm_rad(45.0f) + const zDist = (worldH * 0.5) / Math.tan(fov * 0.5); + const offset = -worldH; + + const eye = [target[0], target[1] + offset, target[2] + zDist]; + const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]); + return { eye, view, fov }; + } + + function compileShader(gl, type, src) { + const shader = gl.createShader(type); + gl.shaderSource(shader, src); + gl.compileShader(shader); + if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Shader compile failed: ${info}`); + } + return shader; + } + + function create(canvas) { + const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); + if(!gl) throw new Error("WebGL is not available in this browser"); + + const program = gl.createProgram(); + gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC)); + gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC)); + gl.linkProgram(program); + if(!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`); + } + + const attribs = { + aUV: gl.getAttribLocation(program, "aUV"), + aPos: gl.getAttribLocation(program, "aPos"), + }; + const uniforms = { + uModel: gl.getUniformLocation(program, "uModel"), + uView: gl.getUniformLocation(program, "uView"), + uProjection: gl.getUniformLocation(program, "uProjection"), + uTexture: gl.getUniformLocation(program, "uTexture"), + uColor: gl.getUniformLocation(program, "uColor"), + }; + + const whiteTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, whiteTexture); + gl.texImage2D( + gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, + new Uint8Array([255, 255, 255, 255]), + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + // Terrain/mesh winding isn't guaranteed consistent relative to this + // preview's fixed camera angle, so backface culling stays off - this + // is an authoring preview, not a performance-sensitive renderer, and + // an invisible mesh is a worse failure mode than a visible backface. + + // Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout) + // into a GL buffer. Returns { buffer, vertexCount }. + function createMesh(floats) { + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW); + return { buffer, vertexCount: floats.length / 5 }; + } + + function createTextureFromImage(image) { + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return texture; + } + + function drawMesh(mesh, model, texture, color, depthWrite, mode) { + gl.depthMask(depthWrite !== false); + gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer); + gl.enableVertexAttribArray(attribs.aUV); + gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0); + gl.enableVertexAttribArray(attribs.aPos); + gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8); + + gl.uniformMatrix4fv(uniforms.uModel, false, model); + gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture); + gl.uniform1i(uniforms.uTexture, 0); + + gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount); + } + + // Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly + // to avoid z-fighting with the terrain surface beneath it. + function buildHighlightMesh(x, y, z) { + const eps = 0.02; + return createMesh(new Float32Array([ + 0, 0, x, y, z + eps, + 0, 0, x + 1, y, z + eps, + 0, 0, x + 1, y + 1, z + eps, + 0, 0, x, y + 1, z + eps, + ])); + } + + // Neighboring chunks are drawn dimmer and semi-transparent, so they + // read as context rather than part of the chunk being edited. + const NEIGHBOR_ALPHA = 0.35; + const NEIGHBOR_DARKEN = 0.5; + + // scene = { + // target: [x, y, z], worldH: number, + // terrain: { mesh, texture } | null, + // models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }], + // neighbors: [{ + // offset: [x,y,z], + // terrain: { mesh, texture } | null, + // models: [{ mesh, texture, color, offset }], + // }], + // highlight: { x, y, z } | null, + // } + function render(scene) { + const width = canvas.width, height = canvas.height; + gl.viewport(0, 0, width, height); + gl.clearColor(0.16, 0.18, 0.22, 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.useProgram(program); + gl.depthMask(true); + + const { view, fov } = computeCamera(scene.target, scene.worldH); + const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000); + gl.uniformMatrix4fv(uniforms.uProjection, false, projection); + gl.uniformMatrix4fv(uniforms.uView, false, view); + + const identity = mat4Identity(new Float32Array(16)); + + if(scene.terrain) { + drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true); + } + + for(const model of scene.models) { + const modelMatrix = mat4Translation(new Float32Array(16), model.offset); + drawMesh(model.mesh, modelMatrix, model.texture, model.color, true); + } + + for(const neighbor of scene.neighbors || []) { + if(neighbor.terrain) { + const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset); + drawMesh( + neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture, + [NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false, + ); + } + + for(const model of neighbor.models) { + const worldOffset = [ + neighbor.offset[0] + model.offset[0], + neighbor.offset[1] + model.offset[1], + neighbor.offset[2] + model.offset[2], + ]; + const modelMatrix = mat4Translation(new Float32Array(16), worldOffset); + const color = [ + model.color[0] * NEIGHBOR_DARKEN, + model.color[1] * NEIGHBOR_DARKEN, + model.color[2] * NEIGHBOR_DARKEN, + model.color[3] * NEIGHBOR_ALPHA, + ]; + drawMesh(model.mesh, modelMatrix, model.texture, color, false); + } + } + + if(scene.highlight) { + const { x, y, z } = scene.highlight; + const highlightMesh = buildHighlightMesh(x, y, z); + drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP); + } + } + + return { gl, createMesh, createTextureFromImage, render }; + } + + return Object.freeze({ create, computeCamera }); +})(); diff --git a/editor/client/public/template.html b/editor/client/public/template.html index 976e6fe4..5ea9aba2 100644 --- a/editor/client/public/template.html +++ b/editor/client/public/template.html @@ -4,8 +4,20 @@ Dusk Map Editor + -
+ +
+ diff --git a/editor/server/server.py b/editor/server/server.py index 07fe3052..fe6d68db 100644 --- a/editor/server/server.py +++ b/editor/server/server.py @@ -7,19 +7,27 @@ Serves editor/client/public/ as static files with smart routing: /index -> index.html API endpoints (all paths relative to project root): - GET /api/ls?path= directory listing - GET /api/read?path= file contents - POST /api/write?path= write file (JSON body: {"content":"..."}) - POST /api/mkdir?path= create directory + GET /api/ls?path= directory listing + GET /api/read?path= file contents + GET /api/find?path=&ext=<.json> recursive file listing by extension + POST /api/write?path= write file (JSON body: {"content":"..."}) + POST /api/mkdir?path= create directory + POST /api/delete?path= delete a file + POST /api/compile-chunk?x=&y=&z= recompile a chunk JSON to .dcf """ import os +import re +import sys import json import mimetypes +import subprocess from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs, unquote from pathlib import Path +CHUNK_COORD_RE = re.compile(r"^-?\d+$") + PORT = 3000 SCRIPT_DIR = Path(__file__).parent.resolve() PUBLIC_DIR = SCRIPT_DIR.parent / "client" / "public" @@ -36,6 +44,8 @@ class Handler(BaseHTTPRequestHandler): self.api_ls(params) elif path == "/api/read": self.api_read(params) + elif path == "/api/find": + self.api_find(params) else: self.send_error(404) else: @@ -48,6 +58,10 @@ class Handler(BaseHTTPRequestHandler): self.api_write(params) elif parsed.path == "/api/mkdir": self.api_mkdir(params) + elif parsed.path == "/api/delete": + self.api_delete(params) + elif parsed.path == "/api/compile-chunk": + self.api_compile_chunk(params) else: self.send_error(404) @@ -158,6 +172,61 @@ class Handler(BaseHTTPRequestHandler): target.mkdir(parents=True, exist_ok=True) self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))}) + def api_delete(self, params): + rel = params.get("path", [""])[0] + target = self.resolve_project_path(rel) + if target is None: + self.send_json({"error": "invalid path"}, 400) + return + if target.is_file(): + target.unlink() + self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))}) + + def api_find(self, params): + rel = params.get("path", [""])[0] + ext = params.get("ext", [""])[0] + target = self.resolve_project_path(rel) + if target is None: + self.send_json({"error": "invalid path"}, 400) + return + if not target.is_dir(): + self.send_json({"error": "not a directory"}, 400) + return + + pattern = f"*{ext}" if ext else "*" + paths = sorted( + str(p.relative_to(target)).replace(os.sep, "/") + for p in target.rglob(pattern) + if p.is_file() + ) + self.send_json({"path": str(target.relative_to(PROJECT_ROOT)), "files": paths}) + + def api_compile_chunk(self, params): + x = params.get("x", [""])[0] + y = params.get("y", [""])[0] + z = params.get("z", [""])[0] + if not (CHUNK_COORD_RE.match(x) and CHUNK_COORD_RE.match(y) and CHUNK_COORD_RE.match(z)): + self.send_json({"error": "invalid chunk coordinates"}, 400) + return + + json_path = PROJECT_ROOT / "assetsraw" / "chunks" / f"chunk_{x}_{y}_{z}.json" + if not json_path.is_file(): + self.send_json({"error": "chunk json not found"}, 404) + return + + result = subprocess.run( + [sys.executable, "-m", "tools.asset.chunk", str(json_path)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + ) + self.send_json({ + "ok": result.returncode == 0, + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + }) + # ------------------------------------------------------------------ # Static file serving # ------------------------------------------------------------------ @@ -194,11 +263,13 @@ class Handler(BaseHTTPRequestHandler): ): fragment = file_path.read_text(encoding="utf-8") shell = template.read_text(encoding="utf-8") - content = shell.replace( - '
', - f'
{fragment}
', - 1, - ).encode("utf-8") + marker = shell.find('id="editor"') + if marker == -1: + content = shell.encode("utf-8") + else: + tag_end = shell.index(">", marker) + 1 + close_idx = shell.index("", tag_end) + content = (shell[:tag_end] + fragment + shell[close_idx:]).encode("utf-8") else: content = file_path.read_bytes()