Refactor map renderer and server API

- Updated index.html to enhance UI with new elements for hidden shapes and catalog CSVs.
- Changed download button to a button element for better accessibility.
- Modified server.js to improve API endpoints:
  - Renamed overlays endpoint to scene for clarity.
  - Updated tile rendering endpoints to use atlas instead of tile coordinates.
  - Added new endpoint for downloading shape catalog CSV files.
  - Removed unused options in build creation.
This commit is contained in:
Marco 2026-03-27 16:28:45 +01:00
commit f93cfc31c8
17 changed files with 2228 additions and 1199 deletions

View file

@ -4,5 +4,3 @@ coverage/
dist/
.env
.env.*
STATIC/
STATIC_REGRET/

View file

@ -0,0 +1,185 @@
shape_code,human_readable_id,description,roof,semitransparency
0x0001,,
0x000B,,
0x0011,,
0x0028,,
0x0030,,
0x0033,door_shape_0033,Auto-derived from DOOR self-shape comparison in USECODE
0x0034,,
0x004B,door_shape_004b,Auto-derived from DOOR self-shape comparison in USECODE
0x005F,,
0x0064,door_shape_0064,Auto-derived from DOOR self-shape comparison in USECODE
0x006C,door_shape_006c,Auto-derived from DOOR self-shape comparison in USECODE
0x0070,yelrail_shape_0070,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0085,,
0x0088,yelrail_shape_0088,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008A,yelrail_shape_008a,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008B,yelrail_shape_008b,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008C,yelrail_shape_008c,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008D,yelrail_shape_008d,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0091,yelrail_shape_0091,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0092,yelrail_shape_0092,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0093,yelrail_shape_0093,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0095,,
0x00AA,barrel_shape_00aa,Auto-derived from BARREL self-shape comparison in USECODE
0x00AD,,
0x00C0,,
0x00D1,,
0x00D4,,
0x00D9,,
0x00DB,,
0x0108,,
0x0111,,
0x0113,,
0x0127,,
0x0135,,
0x0151,barrel_shape_0151,Auto-derived from BARREL self-shape comparison in USECODE
0x0152,barrel_shape_0152,Auto-derived from BARREL self-shape comparison in USECODE
0x0153,barrel_shape_0153,Auto-derived from BARREL self-shape comparison in USECODE
0x0154,barrel_shape_0154,Auto-derived from BARREL self-shape comparison in USECODE
0x0155,barrel_shape_0155,Auto-derived from BARREL self-shape comparison in USECODE
0x0156,,
0x018D,,
0x018E,,
0x0190,,
0x0193,,
0x01AB,,
0x01B4,booty_shape_01b4,Auto-derived from BOOTY self-shape comparison in USECODE
0x01B9,,
0x01BA,,
0x01C1,,
0x01C8,,
0x01CD,,
0x01D9,,
0x01DA,,
0x01DB,,
0x01E4,,
0x01EE,,
0x01F5,,
0x022D,,
0x0251,,
0x025F,,
0x0260,,
0x0277,,
0x0278,,
0x0287,,
0x0289,,
0x028D,,
0x02C9,bbetty_shape_02c9,Auto-derived from BBETTY self-shape comparison in USECODE
0x02CB,,
0x02D8,door_shape_02d8,Auto-derived from DOOR self-shape comparison in USECODE
0x02DC,,
0x02DE,,
0x02DF,booty_shape_02df,Auto-derived from BOOTY self-shape comparison in USECODE
0x02EF,,
0x02F0,,
0x02F5,,
0x02F6,,
0x02F7,,
0x0301,,
0x0308,booty_shape_0308,Auto-derived from BOOTY self-shape comparison in USECODE
0x030C,,
0x0319,,
0x0337,,
0x0338,,
0x033A,,
0x0344,,
0x034B,,
0x0361,,
0x0363,door_shape_0363,Auto-derived from DOOR self-shape comparison in USECODE
0x0371,booty_shape_0371,Auto-derived from BOOTY self-shape comparison in USECODE
0x0373,,
0x037A,door_shape_037a,Auto-derived from DOOR self-shape comparison in USECODE
0x0383,,
0x0384,,
0x0385,,
0x0399,,
0x039A,,
0x039C,,
0x03A1,,
0x03A9,,
0x03AC,,
0x03AD,,
0x03B9,door_shape_03b9,Auto-derived from DOOR self-shape comparison in USECODE
0x03BA,door_shape_03ba,Auto-derived from DOOR self-shape comparison in USECODE
0x0401,,
0x0403,,
0x041C,,
0x0438,,
0x0439,,
0x043A,,
0x043B,,
0x043D,,
0x0443,,
0x044A,,
0x044D,,
0x044E,,
0x0452,,
0x0456,,
0x0457,,
0x0459,,
0x045A,,
0x045D,,
0x046A,,
0x046C,,
0x0476,,
0x04B1,,
0x04B8,,
0x04C8,,
0x04C9,,
0x04D0,,
0x04D1,booty_shape_04d1,Auto-derived from BOOTY self-shape comparison in USECODE
0x04D9,,
0x04E0,,
0x04E6,,
0x04E7,,
0x04F8,,
0x04F9,,
0x04FA,,
0x04FD,,
0x04FE,,
0x0500,,
0x050A,,
0x0510,,
0x0511,,
0x0518,,
0x051A,,
0x0524,,
0x0528,booty_shape_0528,Auto-derived from BOOTY self-shape comparison in USECODE
0x053A,,
0x053B,door_shape_053b,Auto-derived from DOOR self-shape comparison in USECODE
0x054E,,
0x055F,,
0x0561,,
0x056F,door_shape_056f,Auto-derived from DOOR self-shape comparison in USECODE
0x0571,door_shape_0571,Auto-derived from DOOR self-shape comparison in USECODE
0x0573,door_shape_0573,Auto-derived from DOOR self-shape comparison in USECODE
0x0574,door_shape_0574,Auto-derived from DOOR self-shape comparison in USECODE
0x0576,,
0x057A,booty_shape_057a,Auto-derived from BOOTY self-shape comparison in USECODE
0x058F,,
0x0595,,
0x0596,,
0x0597,,
0x05A4,,
0x05A5,,
0x05B1,,
0x05BA,,
0x05D5,booty_shape_05d5,Auto-derived from BOOTY self-shape comparison in USECODE
0x05D6,,
0x05D8,,
0x05D9,,
0x05DA,,
0x05DD,,
0x05DE,,
0x05DF,,
0x05E0,,
0x05E2,,
0x05E6,,
0x05EF,,
0x0606,booty_shape_0606,Auto-derived from BOOTY self-shape comparison in USECODE
0x0616,,
0x062D,,
0x062E,booty_shape_062e,Auto-derived from BOOTY self-shape comparison in USECODE
0x0631,booty_shape_0631,Auto-derived from BOOTY self-shape comparison in USECODE
0x0656,,
1 shape_code,human_readable_id,description,roof,semitransparency
2 0x0001,,
3 0x000B,,
4 0x0011,,
5 0x0028,,
6 0x0030,,
7 0x0033,door_shape_0033,Auto-derived from DOOR self-shape comparison in USECODE
8 0x0034,,
9 0x004B,door_shape_004b,Auto-derived from DOOR self-shape comparison in USECODE
10 0x005F,,
11 0x0064,door_shape_0064,Auto-derived from DOOR self-shape comparison in USECODE
12 0x006C,door_shape_006c,Auto-derived from DOOR self-shape comparison in USECODE
13 0x0070,yelrail_shape_0070,Auto-derived from YELRAIL self-shape comparison in USECODE
14 0x0085,,
15 0x0088,yelrail_shape_0088,Auto-derived from YELRAIL self-shape comparison in USECODE
16 0x008A,yelrail_shape_008a,Auto-derived from YELRAIL self-shape comparison in USECODE
17 0x008B,yelrail_shape_008b,Auto-derived from YELRAIL self-shape comparison in USECODE
18 0x008C,yelrail_shape_008c,Auto-derived from YELRAIL self-shape comparison in USECODE
19 0x008D,yelrail_shape_008d,Auto-derived from YELRAIL self-shape comparison in USECODE
20 0x0091,yelrail_shape_0091,Auto-derived from YELRAIL self-shape comparison in USECODE
21 0x0092,yelrail_shape_0092,Auto-derived from YELRAIL self-shape comparison in USECODE
22 0x0093,yelrail_shape_0093,Auto-derived from YELRAIL self-shape comparison in USECODE
23 0x0095,,
24 0x00AA,barrel_shape_00aa,Auto-derived from BARREL self-shape comparison in USECODE
25 0x00AD,,
26 0x00C0,,
27 0x00D1,,
28 0x00D4,,
29 0x00D9,,
30 0x00DB,,
31 0x0108,,
32 0x0111,,
33 0x0113,,
34 0x0127,,
35 0x0135,,
36 0x0151,barrel_shape_0151,Auto-derived from BARREL self-shape comparison in USECODE
37 0x0152,barrel_shape_0152,Auto-derived from BARREL self-shape comparison in USECODE
38 0x0153,barrel_shape_0153,Auto-derived from BARREL self-shape comparison in USECODE
39 0x0154,barrel_shape_0154,Auto-derived from BARREL self-shape comparison in USECODE
40 0x0155,barrel_shape_0155,Auto-derived from BARREL self-shape comparison in USECODE
41 0x0156,,
42 0x018D,,
43 0x018E,,
44 0x0190,,
45 0x0193,,
46 0x01AB,,
47 0x01B4,booty_shape_01b4,Auto-derived from BOOTY self-shape comparison in USECODE
48 0x01B9,,
49 0x01BA,,
50 0x01C1,,
51 0x01C8,,
52 0x01CD,,
53 0x01D9,,
54 0x01DA,,
55 0x01DB,,
56 0x01E4,,
57 0x01EE,,
58 0x01F5,,
59 0x022D,,
60 0x0251,,
61 0x025F,,
62 0x0260,,
63 0x0277,,
64 0x0278,,
65 0x0287,,
66 0x0289,,
67 0x028D,,
68 0x02C9,bbetty_shape_02c9,Auto-derived from BBETTY self-shape comparison in USECODE
69 0x02CB,,
70 0x02D8,door_shape_02d8,Auto-derived from DOOR self-shape comparison in USECODE
71 0x02DC,,
72 0x02DE,,
73 0x02DF,booty_shape_02df,Auto-derived from BOOTY self-shape comparison in USECODE
74 0x02EF,,
75 0x02F0,,
76 0x02F5,,
77 0x02F6,,
78 0x02F7,,
79 0x0301,,
80 0x0308,booty_shape_0308,Auto-derived from BOOTY self-shape comparison in USECODE
81 0x030C,,
82 0x0319,,
83 0x0337,,
84 0x0338,,
85 0x033A,,
86 0x0344,,
87 0x034B,,
88 0x0361,,
89 0x0363,door_shape_0363,Auto-derived from DOOR self-shape comparison in USECODE
90 0x0371,booty_shape_0371,Auto-derived from BOOTY self-shape comparison in USECODE
91 0x0373,,
92 0x037A,door_shape_037a,Auto-derived from DOOR self-shape comparison in USECODE
93 0x0383,,
94 0x0384,,
95 0x0385,,
96 0x0399,,
97 0x039A,,
98 0x039C,,
99 0x03A1,,
100 0x03A9,,
101 0x03AC,,
102 0x03AD,,
103 0x03B9,door_shape_03b9,Auto-derived from DOOR self-shape comparison in USECODE
104 0x03BA,door_shape_03ba,Auto-derived from DOOR self-shape comparison in USECODE
105 0x0401,,
106 0x0403,,
107 0x041C,,
108 0x0438,,
109 0x0439,,
110 0x043A,,
111 0x043B,,
112 0x043D,,
113 0x0443,,
114 0x044A,,
115 0x044D,,
116 0x044E,,
117 0x0452,,
118 0x0456,,
119 0x0457,,
120 0x0459,,
121 0x045A,,
122 0x045D,,
123 0x046A,,
124 0x046C,,
125 0x0476,,
126 0x04B1,,
127 0x04B8,,
128 0x04C8,,
129 0x04C9,,
130 0x04D0,,
131 0x04D1,booty_shape_04d1,Auto-derived from BOOTY self-shape comparison in USECODE
132 0x04D9,,
133 0x04E0,,
134 0x04E6,,
135 0x04E7,,
136 0x04F8,,
137 0x04F9,,
138 0x04FA,,
139 0x04FD,,
140 0x04FE,,
141 0x0500,,
142 0x050A,,
143 0x0510,,
144 0x0511,,
145 0x0518,,
146 0x051A,,
147 0x0524,,
148 0x0528,booty_shape_0528,Auto-derived from BOOTY self-shape comparison in USECODE
149 0x053A,,
150 0x053B,door_shape_053b,Auto-derived from DOOR self-shape comparison in USECODE
151 0x054E,,
152 0x055F,,
153 0x0561,,
154 0x056F,door_shape_056f,Auto-derived from DOOR self-shape comparison in USECODE
155 0x0571,door_shape_0571,Auto-derived from DOOR self-shape comparison in USECODE
156 0x0573,door_shape_0573,Auto-derived from DOOR self-shape comparison in USECODE
157 0x0574,door_shape_0574,Auto-derived from DOOR self-shape comparison in USECODE
158 0x0576,,
159 0x057A,booty_shape_057a,Auto-derived from BOOTY self-shape comparison in USECODE
160 0x058F,,
161 0x0595,,
162 0x0596,,
163 0x0597,,
164 0x05A4,,
165 0x05A5,,
166 0x05B1,,
167 0x05BA,,
168 0x05D5,booty_shape_05d5,Auto-derived from BOOTY self-shape comparison in USECODE
169 0x05D6,,
170 0x05D8,,
171 0x05D9,,
172 0x05DA,,
173 0x05DD,,
174 0x05DE,,
175 0x05DF,,
176 0x05E0,,
177 0x05E2,,
178 0x05E6,,
179 0x05EF,,
180 0x0606,booty_shape_0606,Auto-derived from BOOTY self-shape comparison in USECODE
181 0x0616,,
182 0x062D,,
183 0x062E,booty_shape_062e,Auto-derived from BOOTY self-shape comparison in USECODE
184 0x0631,booty_shape_0631,Auto-derived from BOOTY self-shape comparison in USECODE
185 0x0656,,

View file

@ -0,0 +1,218 @@
shape_code,human_readable_id,description,roof,semitransparency
0x0001,,
0x0003,,
0x0004,,
0x0005,door_shape_0005,Auto-derived from DOOR self-shape comparison in USECODE
0x0007,,
0x0009,,
0x000A,,
0x000B,,
0x000D,,
0x000F,,
0x0011,,
0x0013,,
0x0015,,
0x0017,,
0x0019,,
0x001B,,
0x001D,,
0x001E,,
0x0028,,
0x0029,,
0x0030,,
0x0033,,
0x0046,door_shape_0046,Auto-derived from DOOR self-shape comparison in USECODE
0x007B,door_shape_007b,Auto-derived from DOOR self-shape comparison in USECODE
0x0095,door_shape_0095,Auto-derived from DOOR self-shape comparison in USECODE
0x00A1,,
0x00A5,,
0x00A9,door_shape_00a9,Auto-derived from DOOR self-shape comparison in USECODE
0x00AD,,
0x00C2,,
0x0100,,
0x0135,,
0x0136,,
0x0137,,
0x0138,,
0x0139,,
0x0158,,
0x0159,,
0x015A,,
0x015B,,
0x0167,REACTOR,Level 1 Reactor
0x0168,REACTOR_CELL,Level 1
0x0187,WALL_EDGE,Not sure
0x0189,ELEVATOR_DOOR_SEGMENT,
0x018D,ELEVATOR_DOOR_LEFT,
0x018E,TELEPAD_RED,
0x0193,ELEVATOR_DOOR_RIGHT,
0x01A2,RADAR_DISH,
0x01A6,COMM_CONSOLE,
0x01BC,LIGHT_TUBE,A tube with a light inside
0x01BF,ELECTRODE,The zappy things that flash the whole screen when destroyed
0x01C1,TUBE_PLATFORM,A platform for square tubes
0x01C6,ELEVATOR_DOOR_LEFT_2,
0x01C7,ELEVATOR_DOOR_RIGHT_2,
0x01C8,WHITE_GRID,It's a white grid no idea
0x01D5,IR_SENSOR,
0x01DA,NARROW_COLUMN_BASE,
0x01DB,TELEPORTER_LIGHTS,
0x01E4,SMALL_BOX_PROP,
0x01EE,PRISON_BARS_DOOR,
0x021D,MINE_LAYER_TRAP,
0x021E,GRATE_FLOOR_GRAY,
0x0251,PLACEHOLDER_KEY_CUBE,Placeholder UI Element
0x0289,,
0x028E,,
0x02DC,FLOOR_PEDESTAL_SLICE,
0x02DE,ELEVATOR_BASE,It's the concave shape under elevators
0x02E0,DOOR_CORNER_LOWER_RIGHT,
0x02E1,,
0x02E2,HEALTH_STATION_TOP,
0x02E3,HEALTH_STATION_BACK,
0x02E5,PRISONER_1,
0x02E7,PRISONER_2,
0x02E8,PRISONER_3,
0x02EF,TURRET_BASE,
0x02F6,NPC_TECH,
0x02FD,npcdeath_shape_02fd,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x030D,,
0x030E,,
0x0315,BAR_STOOL,
0x0318,PLACEHOLDER_CUBE,Placeholder UI element
0x0329,FORCEFIELD_NW,
0x032A,FORCEFIELD_NE,
0x0337,PLACEHOLDER_CUBE_BIG,
0x0338,MECH_1,
0x033A,NUMBERS,
0x034D,BAR_PATRONS_1,
0x0361,PLACEHOLDER_CUBE_RED_BLACK,
0x0368,,
0x0369,,
0x036A,,
0x036B,,
0x037A,,
0x037D,,
0x03A9,,
0x03AA,,
0x03AC,npcdeath_shape_03ac,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x03B0,,
0x03BF,,
0x03C1,,
0x0401,,
0x043D,,
0x0442,,
0x0443,,
0x044F,,
0x0452,,
0x0457,,
0x0476,,
0x0493,,
0x04B1,,
0x04B8,,
0x04C6,,
0x04C8,wallgun_shape_04c8,Auto-derived from WALLGUN self-shape comparison in USECODE
0x04C9,,
0x04D0,,
0x04D5,,
0x04D9,,
0x04DC,,
0x04E0,,
0x04E7,,
0x04EE,,
0x04F8,,
0x0500,,
0x0524,,
0x053A,,
0x054F,,
0x0561,,
0x005F,,
0x0070,yelrail_shape_0070,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0085,,
0x0088,yelrail_shape_0088,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008A,yelrail_shape_008a,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008B,yelrail_shape_008b,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008C,yelrail_shape_008c,Auto-derived from YELRAIL self-shape comparison in USECODE
0x008D,yelrail_shape_008d,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0091,yelrail_shape_0091,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0092,yelrail_shape_0092,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0093,yelrail_shape_0093,Auto-derived from YELRAIL self-shape comparison in USECODE
0x0099,door_shape_0099,Auto-derived from DOOR self-shape comparison in USECODE
0x00AA,barrel_shape_00aa,Auto-derived from BARREL self-shape comparison in USECODE
0x00D1,,
0x0108,wallgun_shape_0108,Auto-derived from WALLGUN self-shape comparison in USECODE
0x0111,,
0x0113,wallgun_shape_0113,Auto-derived from WALLGUN self-shape comparison in USECODE
0x0141,,
0x0151,barrel_shape_0151,Auto-derived from BARREL self-shape comparison in USECODE
0x0152,BARREL_YELLOW_SIDEWAYS_0152,Auto-derived from BARREL self-shape comparison in USECODE
0x0153,BARREL_YELLOW_SIDEWAYS_0153,Auto-derived from BARREL self-shape comparison in USECODE
0x0154,barrel_shape_0154,Auto-derived from BARREL self-shape comparison in USECODE
0x0155,barrel_shape_0155,Auto-derived from BARREL self-shape comparison in USECODE
0x017F,,
0x018F,,
0x0196,,
0x01B4,npcdeath_shape_01b4,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x01B9,wallgun_shape_01b9,Auto-derived from WALLGUN self-shape comparison in USECODE
0x01BA,wallgun_shape_01ba,Auto-derived from WALLGUN self-shape comparison in USECODE
0x01CD,wallgun_shape_01cd,Auto-derived from WALLGUN self-shape comparison in USECODE
0x01D9,,
0x025F,wallgun_shape_025f,Auto-derived from WALLGUN self-shape comparison in USECODE
0x0260,wallgun_shape_0260,Auto-derived from WALLGUN self-shape comparison in USECODE
0x02C3,,
0x02C4,,
0x02C9,bbetty_shape_02c9,Auto-derived from BBETTY self-shape comparison in USECODE
0x02CB,,
0x02DF,npcdeath_shape_02df,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x02F0,wallgun_shape_02f0,Auto-derived from WALLGUN self-shape comparison in USECODE
0x02F5,,
0x02F7,,
0x030A,door_shape_030a,Auto-derived from DOOR self-shape comparison in USECODE
0x030B,door_shape_030b,Auto-derived from DOOR self-shape comparison in USECODE
0x0319,npcdeath_shape_0319,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x033C,npc_shape_033c,Auto-derived from NPC self-shape comparison in USECODE
0x0344,,
0x0371,npcdeath_shape_0371,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x0373,wallgun_shape_0373,Auto-derived from WALLGUN self-shape comparison in USECODE
0x0383,,
0x0384,npcdeath_shape_0384,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x0385,,
0x0399,wallgun_shape_0399,Auto-derived from WALLGUN self-shape comparison in USECODE
0x03A1,wallgun_shape_03a1,Auto-derived from WALLGUN self-shape comparison in USECODE
0x03F8,,
0x03FF,,
0x0438,,
0x0439,,
0x043A,,
0x043B,,
0x043F,door2_shape_043f,Auto-derived from DOOR2 self-shape comparison in USECODE
0x0440,door2_shape_0440,Auto-derived from DOOR2 self-shape comparison in USECODE
0x044A,,
0x0456,,
0x0459,,
0x045A,,
0x045D,,
0x045E,,
0x045F,,
0x0460,,
0x0461,,
0x0470,,
0x0471,,
0x04D1,npcdeath_shape_04d1,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x04E6,,
0x04F9,,
0x04FA,,
0x04FD,,
0x04FE,,
0x050A,,
0x0511,,
0x0518,,
0x0528,npcdeath_shape_0528,Auto-derived from NPCDEATH self-shape comparison in USECODE
0x052C,,
0x0576,,
0x057A,,
0x057F,,
0x0580,,
0x058F,,
0x0596,,
0x059C,,
1 shape_code,human_readable_id,description,roof,semitransparency
2 0x0001,,
3 0x0003,,
4 0x0004,,
5 0x0005,door_shape_0005,Auto-derived from DOOR self-shape comparison in USECODE
6 0x0007,,
7 0x0009,,
8 0x000A,,
9 0x000B,,
10 0x000D,,
11 0x000F,,
12 0x0011,,
13 0x0013,,
14 0x0015,,
15 0x0017,,
16 0x0019,,
17 0x001B,,
18 0x001D,,
19 0x001E,,
20 0x0028,,
21 0x0029,,
22 0x0030,,
23 0x0033,,
24 0x0046,door_shape_0046,Auto-derived from DOOR self-shape comparison in USECODE
25 0x007B,door_shape_007b,Auto-derived from DOOR self-shape comparison in USECODE
26 0x0095,door_shape_0095,Auto-derived from DOOR self-shape comparison in USECODE
27 0x00A1,,
28 0x00A5,,
29 0x00A9,door_shape_00a9,Auto-derived from DOOR self-shape comparison in USECODE
30 0x00AD,,
31 0x00C2,,
32 0x0100,,
33 0x0135,,
34 0x0136,,
35 0x0137,,
36 0x0138,,
37 0x0139,,
38 0x0158,,
39 0x0159,,
40 0x015A,,
41 0x015B,,
42 0x0167,REACTOR,Level 1 Reactor
43 0x0168,REACTOR_CELL,Level 1
44 0x0187,WALL_EDGE,Not sure
45 0x0189,ELEVATOR_DOOR_SEGMENT,
46 0x018D,ELEVATOR_DOOR_LEFT,
47 0x018E,TELEPAD_RED,
48 0x0193,ELEVATOR_DOOR_RIGHT,
49 0x01A2,RADAR_DISH,
50 0x01A6,COMM_CONSOLE,
51 0x01BC,LIGHT_TUBE,A tube with a light inside
52 0x01BF,ELECTRODE,The zappy things that flash the whole screen when destroyed
53 0x01C1,TUBE_PLATFORM,A platform for square tubes
54 0x01C6,ELEVATOR_DOOR_LEFT_2,
55 0x01C7,ELEVATOR_DOOR_RIGHT_2,
56 0x01C8,WHITE_GRID,It's a white grid no idea
57 0x01D5,IR_SENSOR,
58 0x01DA,NARROW_COLUMN_BASE,
59 0x01DB,TELEPORTER_LIGHTS,
60 0x01E4,SMALL_BOX_PROP,
61 0x01EE,PRISON_BARS_DOOR,
62 0x021D,MINE_LAYER_TRAP,
63 0x021E,GRATE_FLOOR_GRAY,
64 0x0251,PLACEHOLDER_KEY_CUBE,Placeholder UI Element
65 0x0289,,
66 0x028E,,
67 0x02DC,FLOOR_PEDESTAL_SLICE,
68 0x02DE,ELEVATOR_BASE,It's the concave shape under elevators
69 0x02E0,DOOR_CORNER_LOWER_RIGHT,
70 0x02E1,,
71 0x02E2,HEALTH_STATION_TOP,
72 0x02E3,HEALTH_STATION_BACK,
73 0x02E5,PRISONER_1,
74 0x02E7,PRISONER_2,
75 0x02E8,PRISONER_3,
76 0x02EF,TURRET_BASE,
77 0x02F6,NPC_TECH,
78 0x02FD,npcdeath_shape_02fd,Auto-derived from NPCDEATH self-shape comparison in USECODE
79 0x030D,,
80 0x030E,,
81 0x0315,BAR_STOOL,
82 0x0318,PLACEHOLDER_CUBE,Placeholder UI element
83 0x0329,FORCEFIELD_NW,
84 0x032A,FORCEFIELD_NE,
85 0x0337,PLACEHOLDER_CUBE_BIG,
86 0x0338,MECH_1,
87 0x033A,NUMBERS,
88 0x034D,BAR_PATRONS_1,
89 0x0361,PLACEHOLDER_CUBE_RED_BLACK,
90 0x0368,,
91 0x0369,,
92 0x036A,,
93 0x036B,,
94 0x037A,,
95 0x037D,,
96 0x03A9,,
97 0x03AA,,
98 0x03AC,npcdeath_shape_03ac,Auto-derived from NPCDEATH self-shape comparison in USECODE
99 0x03B0,,
100 0x03BF,,
101 0x03C1,,
102 0x0401,,
103 0x043D,,
104 0x0442,,
105 0x0443,,
106 0x044F,,
107 0x0452,,
108 0x0457,,
109 0x0476,,
110 0x0493,,
111 0x04B1,,
112 0x04B8,,
113 0x04C6,,
114 0x04C8,wallgun_shape_04c8,Auto-derived from WALLGUN self-shape comparison in USECODE
115 0x04C9,,
116 0x04D0,,
117 0x04D5,,
118 0x04D9,,
119 0x04DC,,
120 0x04E0,,
121 0x04E7,,
122 0x04EE,,
123 0x04F8,,
124 0x0500,,
125 0x0524,,
126 0x053A,,
127 0x054F,,
128 0x0561,,
129 0x005F,,
130 0x0070,yelrail_shape_0070,Auto-derived from YELRAIL self-shape comparison in USECODE
131 0x0085,,
132 0x0088,yelrail_shape_0088,Auto-derived from YELRAIL self-shape comparison in USECODE
133 0x008A,yelrail_shape_008a,Auto-derived from YELRAIL self-shape comparison in USECODE
134 0x008B,yelrail_shape_008b,Auto-derived from YELRAIL self-shape comparison in USECODE
135 0x008C,yelrail_shape_008c,Auto-derived from YELRAIL self-shape comparison in USECODE
136 0x008D,yelrail_shape_008d,Auto-derived from YELRAIL self-shape comparison in USECODE
137 0x0091,yelrail_shape_0091,Auto-derived from YELRAIL self-shape comparison in USECODE
138 0x0092,yelrail_shape_0092,Auto-derived from YELRAIL self-shape comparison in USECODE
139 0x0093,yelrail_shape_0093,Auto-derived from YELRAIL self-shape comparison in USECODE
140 0x0099,door_shape_0099,Auto-derived from DOOR self-shape comparison in USECODE
141 0x00AA,barrel_shape_00aa,Auto-derived from BARREL self-shape comparison in USECODE
142 0x00D1,,
143 0x0108,wallgun_shape_0108,Auto-derived from WALLGUN self-shape comparison in USECODE
144 0x0111,,
145 0x0113,wallgun_shape_0113,Auto-derived from WALLGUN self-shape comparison in USECODE
146 0x0141,,
147 0x0151,barrel_shape_0151,Auto-derived from BARREL self-shape comparison in USECODE
148 0x0152,BARREL_YELLOW_SIDEWAYS_0152,Auto-derived from BARREL self-shape comparison in USECODE
149 0x0153,BARREL_YELLOW_SIDEWAYS_0153,Auto-derived from BARREL self-shape comparison in USECODE
150 0x0154,barrel_shape_0154,Auto-derived from BARREL self-shape comparison in USECODE
151 0x0155,barrel_shape_0155,Auto-derived from BARREL self-shape comparison in USECODE
152 0x017F,,
153 0x018F,,
154 0x0196,,
155 0x01B4,npcdeath_shape_01b4,Auto-derived from NPCDEATH self-shape comparison in USECODE
156 0x01B9,wallgun_shape_01b9,Auto-derived from WALLGUN self-shape comparison in USECODE
157 0x01BA,wallgun_shape_01ba,Auto-derived from WALLGUN self-shape comparison in USECODE
158 0x01CD,wallgun_shape_01cd,Auto-derived from WALLGUN self-shape comparison in USECODE
159 0x01D9,,
160 0x025F,wallgun_shape_025f,Auto-derived from WALLGUN self-shape comparison in USECODE
161 0x0260,wallgun_shape_0260,Auto-derived from WALLGUN self-shape comparison in USECODE
162 0x02C3,,
163 0x02C4,,
164 0x02C9,bbetty_shape_02c9,Auto-derived from BBETTY self-shape comparison in USECODE
165 0x02CB,,
166 0x02DF,npcdeath_shape_02df,Auto-derived from NPCDEATH self-shape comparison in USECODE
167 0x02F0,wallgun_shape_02f0,Auto-derived from WALLGUN self-shape comparison in USECODE
168 0x02F5,,
169 0x02F7,,
170 0x030A,door_shape_030a,Auto-derived from DOOR self-shape comparison in USECODE
171 0x030B,door_shape_030b,Auto-derived from DOOR self-shape comparison in USECODE
172 0x0319,npcdeath_shape_0319,Auto-derived from NPCDEATH self-shape comparison in USECODE
173 0x033C,npc_shape_033c,Auto-derived from NPC self-shape comparison in USECODE
174 0x0344,,
175 0x0371,npcdeath_shape_0371,Auto-derived from NPCDEATH self-shape comparison in USECODE
176 0x0373,wallgun_shape_0373,Auto-derived from WALLGUN self-shape comparison in USECODE
177 0x0383,,
178 0x0384,npcdeath_shape_0384,Auto-derived from NPCDEATH self-shape comparison in USECODE
179 0x0385,,
180 0x0399,wallgun_shape_0399,Auto-derived from WALLGUN self-shape comparison in USECODE
181 0x03A1,wallgun_shape_03a1,Auto-derived from WALLGUN self-shape comparison in USECODE
182 0x03F8,,
183 0x03FF,,
184 0x0438,,
185 0x0439,,
186 0x043A,,
187 0x043B,,
188 0x043F,door2_shape_043f,Auto-derived from DOOR2 self-shape comparison in USECODE
189 0x0440,door2_shape_0440,Auto-derived from DOOR2 self-shape comparison in USECODE
190 0x044A,,
191 0x0456,,
192 0x0459,,
193 0x045A,,
194 0x045D,,
195 0x045E,,
196 0x045F,,
197 0x0460,,
198 0x0461,,
199 0x0470,,
200 0x0471,,
201 0x04D1,npcdeath_shape_04d1,Auto-derived from NPCDEATH self-shape comparison in USECODE
202 0x04E6,,
203 0x04F9,,
204 0x04FA,,
205 0x04FD,,
206 0x04FE,,
207 0x050A,,
208 0x0511,,
209 0x0518,,
210 0x0528,npcdeath_shape_0528,Auto-derived from NPCDEATH self-shape comparison in USECODE
211 0x052C,,
212 0x0576,,
213 0x057A,,
214 0x057F,,
215 0x0580,,
216 0x058F,,
217 0x0596,,
218 0x059C,,

View file

@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:20-alpine AS base
WORKDIR /app
@ -6,6 +6,31 @@ COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY src ./src
COPY Catalogs ./Catalogs
ENV PORT=3000
EXPOSE 3000
FROM base AS dev
CMD ["npm", "start"]
FROM base AS precache
COPY STATIC ./STATIC
COPY STATIC_REGRET ./STATIC_REGRET
RUN npm run build-cache
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=precache /app/package.json ./package.json
COPY --from=precache /app/package-lock.json ./package-lock.json
COPY --from=precache /app/node_modules ./node_modules
COPY --from=precache /app/src ./src
COPY --from=precache /app/Catalogs ./Catalogs
COPY --from=precache /app/.cache ./.cache
ENV PORT=3000
EXPOSE 3000

View file

@ -1,13 +1,13 @@
# Crusader Map Renderer
Node web app that renders Crusader maps on the server and streams only finished PNG tiles to the browser.
Node web app that decodes Crusader maps into cached sprite atlases plus scene JSON, then renders the scene directly in the browser.
## Goals
- Keep Crusader source assets server-side.
- Detect maps from `STATIC` and `STATIC_REGRET` automatically.
- Build map render state on demand after the user selects a map.
- Serve large maps as draggable and zoomable image tiles.
- Build map scene caches on demand after the user selects a map.
- Serve cached atlas images and scene JSON so the browser reconstructs the view client-side.
- Run locally with Node or inside Docker.
## Local Run
@ -25,8 +25,27 @@ Viewer behavior:
- drag with the mouse or one finger to pan
- use the scroll wheel to zoom directly at the pointer
- pinch to zoom on touch devices
- toggle roofs and editor-only elements independently before building
- when editor-only elements are enabled, the base map excludes those records and the original editor shapes render as interactive overlay sprites with hover metadata
- toggle roofs and editor-only elements independently without rebuilding; the client filters one full cached scene payload
- inspect mode lets you pin a shape tooltip, hide a single instance, and restore hidden instances from the left panel
- PNG export is generated in the browser from the cached scene instead of being rasterized server-side
- hidden instances can be exported as JSON and each catalog CSV can be downloaded from the viewer
- catalog CSV rows support `roof` and `semitransparency` boolean overrides; leave them blank to use decoded defaults, or set `true`/`false` per shape
## Cache Warming
Build atlas and scene cache artifacts outside the request path:
```powershell
cd map_renderer
npm run build-cache
```
Optional focused warmup:
```powershell
cd map_renderer
npm run build-cache -- remorse 1
```
The app expects asset folders under the app root:
@ -35,22 +54,32 @@ The app expects asset folders under the app root:
## Docker Run
The Docker image excludes the Crusader assets on purpose. Mount them at runtime so they stay outside the image and are never served directly to clients.
The `dev` image stays light and expects Crusader assets to be mounted at runtime.
```powershell
cd map_renderer
docker build -t crusader-map-renderer .
docker build --target dev -t crusader-map-renderer:dev .
docker run --rm -p 3000:3000 `
-v ${PWD}/STATIC:/app/STATIC:ro `
-v ${PWD}/STATIC_REGRET:/app/STATIC_REGRET:ro `
crusader-map-renderer
crusader-map-renderer:dev
```
If only one game is available, mount only that folder.
Production image with prebuilt cache artifacts and no raw `STATIC` assets in the final layer:
```powershell
cd map_renderer
docker build --target production -t crusader-map-renderer:prod .
docker run --rm -p 3000:3000 crusader-map-renderer:prod
```
The production target copies `STATIC` and `STATIC_REGRET` only into the intermediate precache stage, runs `npm run build-cache`, then ships just `src`, `Catalogs`, `node_modules`, and `.cache` in the final image.
## Docker Compose
The compose file mounts `STATIC` and `STATIC_REGRET` from the host filesystem into the container as read-only volumes. They are excluded from the image build by `.dockerignore`, so the assets are never copied into the image.
The compose file targets the lightweight `dev` image and mounts `STATIC` and `STATIC_REGRET` from the host filesystem as read-only volumes.
```powershell
cd map_renderer
@ -62,9 +91,10 @@ docker compose up --build
- `GET /api/maps` returns the detected catalog.
- `POST /api/builds` starts or reuses a build.
- `GET /api/builds/:id` returns build status.
- `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and tile settings.
- `GET /api/maps/:game/:mapId/overlays?buildId=...` returns interactive overlay records for editor-only content.
- `GET /api/maps/:game/:mapId/overlays/:overlayId.webp?buildId=...` returns the rendered sprite for one overlay item.
- `GET /api/maps/:game/:mapId/tiles/:tileX/:tileY.png?buildId=...` returns rendered PNG tiles.
- `GET /api/maps/:game/:mapId/metadata?buildId=...` returns map bounds and scene metadata.
- `GET /api/maps/:game/:mapId/scene?buildId=...` returns the cached atlas-backed scene payload.
- `GET /api/maps/:game/:mapId/atlases/:atlasId.png?buildId=...` returns a cached packed sprite atlas.
- `GET /api/maps/:game/:mapId/inspect?buildId=...` returns the same per-instance shape metadata used for inspection.
- `GET /api/catalogs/:game.csv` returns the source catalog CSV for that game.
No raw Crusader asset files are exposed over HTTP.

View file

@ -2,6 +2,7 @@ services:
map-renderer:
build:
context: .
target: dev
ports:
- "3000:3000"
environment:

View file

@ -6,7 +6,8 @@
"description": "Server-side tiled Crusader map renderer for browser viewing.",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
"dev": "node --watch src/server.js",
"build-cache": "node src/build-cache.js"
},
"engines": {
"node": ">=20"

View file

@ -49,3 +49,83 @@ Open questions for phase 2:
- which helper/editor families should stay as overlay sprites versus gain their own visibility toggles
- what exact metadata fields are reliable enough to expose in the tooltip long-term
- whether some editor-only entries should be clustered, filtered, or toggled by family to keep dense maps usable
## Phase 3
Goal: replace server-side full-map raster composition with a cached atlas-plus-scene-data pipeline that the client renders directly.
- stop baking the playable map and editor-only items into one server-rendered visual surface
- have the server decode every shape needed for a map build, including editor/debug/usecode shapes, and pack them into one or more cached atlas images
- emit cached JSON scene data that tells the client which atlas sprite to draw, where to place it, what metadata it exposes, and how it should be identified in the UI
- reuse the existing usecode shape catalog CSV files in `map_renderer/Catalogs` as part of the build pipeline so shape names and other catalog metadata flow into the exported scene data
- keep the catalog CSV files inside the Docker image build context rather than mounting them separately in `compose.yaml`; they are local source assets and should be burned into the image
- add an explicit npm cache-generation script that prebuilds atlas images and scene JSON for every map outside the web request path; this can be run manually or during Docker/container initialization
- keep the live viewer on cached artifacts by default and regenerate only missing or stale atlas/map data on demand when a request needs them
Phase 3 implementation choice:
- the primary server artifact becomes cached render data per map build rather than cached raster tiles
- cache artifacts are per-map for now; do not generate separate atlas/scene folders for roof/editor visibility modes because those filters will be applied entirely in the client from one full scene payload
- each cached map build should include:
- atlas image data containing all decoded shape frames required for that map, including editor-only items
- scene JSON listing every shape instance with atlas coordinates, map placement, draw order or layer hints, ID, and enough metadata for the client to decide whether the instance is a roof, editor/debug object, helper geometry, or normal map geometry
- build metadata sufficient to validate cache freshness against source assets, decoding rules, and the catalog CSV inputs
- atlas packing and scene serialization should deduplicate repeated shapes so the client draws many instances from a small packed sprite set instead of receiving repeated rendered pixels
- cache invalidation should key off map inputs plus a content/version fingerprint that includes the relevant catalog CSV data so name edits and decoding changes invalidate stale cached outputs cleanly
Phase 3 metadata proposal:
- keep per-instance records compact and focused on placement/runtime state: instance ID, sprite ID, shape-definition ID, draw order, source, world coordinates, flags, map/NPC linkage, and screen rect
- move repeated descriptive data into shared per-shape definitions in the same scene JSON: shape code, display name, description, family, roof/editor/helper traits, and visibility tags
- keep sprite packing data separate from shape definitions so multiple frames can share one shape definition while still pointing at distinct packed atlas entries
- this reduces JSON duplication while keeping the client fully self-sufficient for filtering, inspection, and export operations
Phase 3 client/UI work:
- replace the current base-map tile surface plus overlay composition with one client-side scene renderer driven entirely by cached atlas plus scene JSON
- preserve inspect mode, but change click behavior so when inspect mode is enabled a clicked shape pins its tooltip in place until the same shape is clicked again or a different shape is selected
- ensure the pinned tooltip text remains selectable and copyable
- add an eye icon to the tooltip that hides the currently selected shape instance from the scene without deleting its metadata
- add a left-panel section that lists hidden shapes by name and ID and allows restoring each hidden shape to visibility
- add a button that exports the current hidden-shape instance list as JSON
- add one export button for each shape database CSV so the current catalog sources can be downloaded directly from the viewer workflow
- make the left column scroll independently from the map viewer
- make the left column horizontally resizable, with the renderer always filling the remaining viewport width and height
Phase 3 server/runtime work:
- separate cache warming from the web server process with a dedicated npm script such as `npm run build-cache` or similar
- optionally call that script during Docker initialization so containers can start warm without forcing atlas generation into the request-serving process
- on normal requests, serve cached atlas and scene artifacts when present; if an artifact is absent or invalid, regenerate just the required map data and then serve it
- keep the runtime response machine-friendly so the client can reconstruct scene state without server-rendered presentation assumptions
-- add a production Docker build step that bakes the fully precached atlas images and scene JSON into the production image so the container can serve maps without the original `STATIC` source files present
- ensure the Docker build step excludes raw `STATIC` input sources from the final image layers: only the compiled/packed atlas outputs and scene JSON should be included in the production image
- keep the development image light and mount `STATIC` locally (or read from the workspace) so developers can iterate on source assets without rebuilding the image; the dev image should not precache by default
Open questions for phase 3:
- atlas artifacts stay strictly per-map for now
- prefer compact per-instance records plus shared shape-definition metadata in the JSON payload
- whether hidden-shape state should stay purely client-side for a session or also become part of URL/share state later
- keep the scene renderer DOM/canvas based for now
## Phase 4
Goal: add guarded catalog-editing tools for shape naming once the atlas-plus-scene-data pipeline is stable.
- add a shape-name editor UI that can update the usecode shape catalog CSV files from inside the map viewer workflow
- keep catalog editing disabled in the default server mode so externally exposed viewers remain read-only
- expose catalog editing only through a special server mode with a separate npm target if practical, for example a dedicated dev/admin run mode rather than the default `start` or `dev` targets
- make Phase 4 build directly on the Phase 3 scene data so edits operate on stable shape IDs and catalog-backed names instead of ad hoc tooltip text
Phase 4 implementation choice:
- prefer explicit opt-in server modes such as a dedicated admin/edit target over runtime query flags so editing capability cannot be enabled accidentally
- any catalog write path should validate CSV targets, preserve formatting conventions, and trigger cache invalidation for affected maps so renamed shapes show up in freshly generated atlas data
Open questions for phase 4:
- whether catalog edits should write directly to the CSV files or stage edits through a review queue first
- whether editing should be limited to names only or eventually extend to richer catalog metadata
- how much authentication or local-only binding is needed beyond the separate npm target if the editor is ever exposed outside a purely local workflow

View file

@ -0,0 +1,67 @@
import { BuildManager } from "./lib/build-manager.js";
import { detectCatalog, getGameConfig } from "./lib/catalog.js";
function parseArgs(argv) {
const parsed = {
game: null,
mapId: null
};
for (const arg of argv) {
if (arg.startsWith("--game=")) {
parsed.game = arg.slice("--game=".length);
continue;
}
if (arg.startsWith("--map=")) {
parsed.mapId = Number.parseInt(arg.slice("--map=".length), 10);
continue;
}
if (!parsed.game && Number.isNaN(Number(arg))) {
parsed.game = arg;
continue;
}
if (!Number.isNaN(Number(arg))) {
parsed.mapId = Number.parseInt(arg, 10);
}
}
return parsed;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const catalog = detectCatalog();
const builds = new BuildManager(catalog);
const games = args.game ? catalog.games.filter((game) => game.id === args.game) : catalog.games;
if (!games.length) {
throw new Error(args.game ? `No detected catalog entry for game ${args.game}` : "No detected maps to cache");
}
for (const game of games) {
const gameConfig = getGameConfig(game.id);
if (!gameConfig) {
throw new Error(`Missing game config for ${game.id}`);
}
const maps = Number.isInteger(args.mapId) ? game.maps.filter((map) => map.id === args.mapId) : game.maps;
if (!maps.length) {
throw new Error(`No detected map ${args.mapId} for game ${game.id}`);
}
for (const map of maps) {
const label = `${game.id} map ${map.id}`;
console.log(`warming ${label}`);
const job = await builds.createOrReuseBuild(gameConfig, map.id);
await job.promise;
if (job.status !== "ready") {
throw new Error(`Cache build failed for ${label}: ${job.error ?? "unknown error"}`);
}
console.log(`ready ${label} fingerprint=${job.fingerprint} atlases=${job.metadata.sceneSummary.atlasCount}`);
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View file

@ -7,9 +7,12 @@ const __dirname = path.dirname(__filename);
export const APP_ROOT = path.resolve(__dirname, "..");
export const PUBLIC_ROOT = path.join(APP_ROOT, "src", "public");
export const TILE_SIZE = Number.parseInt(process.env.TILE_SIZE ?? "1024", 10);
export const ATLAS_MAX_SIZE = Number.parseInt(process.env.ATLAS_MAX_SIZE ?? "4096", 10);
export const PORT = Number.parseInt(process.env.PORT ?? "3000", 10);
export const CACHE_ROOT = path.join(APP_ROOT, ".cache");
export const TILE_CACHE_ROOT = path.join(CACHE_ROOT, "tiles");
export const SCENE_CACHE_ROOT = path.join(CACHE_ROOT, "scene-cache");
export const CATALOG_ROOT = path.join(APP_ROOT, "Catalogs");
export const GAMES = [
{
id: "remorse",

View file

@ -0,0 +1,105 @@
import { ATLAS_MAX_SIZE } from "../config.js";
function createAtlas(index, maxSize, padding) {
return {
id: `atlas-${index}`,
maxSize,
padding,
width: 0,
height: 0,
cursorX: padding,
cursorY: padding,
shelfHeight: 0,
sprites: []
};
}
function finalizeAtlas(atlas) {
return {
id: atlas.id,
width: Math.max(1, atlas.width + atlas.padding),
height: Math.max(1, atlas.height + atlas.padding),
sprites: atlas.sprites
};
}
function tryPlaceSprite(atlas, sprite) {
const paddedWidth = sprite.width + atlas.padding;
const paddedHeight = sprite.height + atlas.padding;
if (paddedWidth + atlas.padding > atlas.maxSize || paddedHeight + atlas.padding > atlas.maxSize) {
throw new Error(`Sprite ${sprite.id} exceeds atlas limit ${atlas.maxSize}`);
}
if (atlas.cursorX + sprite.width > atlas.maxSize - atlas.padding) {
atlas.cursorX = atlas.padding;
atlas.cursorY += atlas.shelfHeight + atlas.padding;
atlas.shelfHeight = 0;
}
if (atlas.cursorY + sprite.height > atlas.maxSize - atlas.padding) {
return null;
}
const placed = {
id: sprite.id,
x: atlas.cursorX,
y: atlas.cursorY,
width: sprite.width,
height: sprite.height
};
atlas.sprites.push(placed);
atlas.width = Math.max(atlas.width, atlas.cursorX + sprite.width);
atlas.height = Math.max(atlas.height, atlas.cursorY + sprite.height);
atlas.cursorX += paddedWidth;
atlas.shelfHeight = Math.max(atlas.shelfHeight, paddedHeight);
return placed;
}
export function packSprites(rawSprites, options = {}) {
const maxAtlasSize = options.maxAtlasSize ?? ATLAS_MAX_SIZE;
const padding = options.padding ?? 1;
const sprites = [...rawSprites].sort((left, right) => {
const leftMax = Math.max(left.width, left.height);
const rightMax = Math.max(right.width, right.height);
if (leftMax !== rightMax) {
return rightMax - leftMax;
}
const leftArea = left.width * left.height;
const rightArea = right.width * right.height;
if (leftArea !== rightArea) {
return rightArea - leftArea;
}
return left.id.localeCompare(right.id);
});
const atlases = [];
const placements = new Map();
let atlas = createAtlas(0, maxAtlasSize, padding);
for (const sprite of sprites) {
let placed = tryPlaceSprite(atlas, sprite);
if (!placed) {
atlases.push(finalizeAtlas(atlas));
atlas = createAtlas(atlases.length, maxAtlasSize, padding);
placed = tryPlaceSprite(atlas, sprite);
}
placements.set(sprite.id, {
atlasId: atlas.id,
x: placed.x,
y: placed.y,
width: placed.width,
height: placed.height
});
}
if (atlas.sprites.length || atlases.length === 0) {
atlases.push(finalizeAtlas(atlas));
}
return {
atlases,
placements
};
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,150 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { GAMES } from "../config.js";
import { CATALOG_ROOT, GAMES } from "../config.js";
import { getMapSummaries, resolveStaticFile } from "./formats.js";
const CATALOG_FILE_BY_GAME = {
remorse: "usecode_shape_catalog_remorse.csv",
regret: "usecode_shape_catalog_regret.csv"
};
const shapeCatalogCache = new Map();
function sha1(value) {
return crypto.createHash("sha1").update(value).digest("hex");
}
function parseCsvLine(line) {
const values = [];
let current = "";
let inQuotes = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === '"') {
if (inQuotes && line[index + 1] === '"') {
current += '"';
index += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (char === "," && !inQuotes) {
values.push(current);
current = "";
continue;
}
current += char;
}
values.push(current);
return values;
}
function parseOptionalBoolean(value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (!normalized) {
return null;
}
if (["true", "1", "yes", "y"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "n"].includes(normalized)) {
return false;
}
return null;
}
function getRowValue(row, ...keys) {
for (const key of keys) {
if (Object.hasOwn(row, key)) {
return row[key];
}
}
return "";
}
function normalizeCatalogEntry(row) {
const shapeCode = Number.parseInt(String(getRowValue(row, "shape_code", "shapeCode", "ShapeCode")).trim(), 16);
if (!Number.isInteger(shapeCode)) {
return null;
}
return {
shapeCode,
shapeCodeHex: `0x${shapeCode.toString(16).padStart(4, "0")}`,
humanReadableId: String(getRowValue(row, "human_readable_id", "humanReadableId", "HumanReadableId")).trim(),
description: String(getRowValue(row, "description", "Description")).trim(),
roof: parseOptionalBoolean(getRowValue(row, "roof", "Roof")),
semitransparency: parseOptionalBoolean(getRowValue(row, "semitransparency", "semi_transparency", "Semitransparency", "SemiTransparency"))
};
}
function parseCatalogCsv(text) {
const lines = text
.split(/\r?\n/u)
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
if (!lines.length) {
return new Map();
}
const headers = parseCsvLine(lines[0]).map((value) => value.trim());
const entries = new Map();
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
const values = parseCsvLine(lines[lineIndex]);
const row = {};
for (let headerIndex = 0; headerIndex < headers.length; headerIndex += 1) {
row[headers[headerIndex]] = values[headerIndex] ?? "";
}
const entry = normalizeCatalogEntry(row);
if (entry) {
entries.set(entry.shapeCode, entry);
}
}
return entries;
}
function getCatalogPath(gameId) {
const fileName = CATALOG_FILE_BY_GAME[gameId];
if (!fileName) {
return null;
}
return path.join(CATALOG_ROOT, fileName);
}
export function getShapeCatalogFile(gameId) {
return getCatalogPath(gameId);
}
export function getShapeCatalog(gameId) {
const filePath = getCatalogPath(gameId);
if (!filePath || !fs.existsSync(filePath)) {
return {
filePath,
digest: "missing",
entries: new Map()
};
}
const stat = fs.statSync(filePath);
const stamp = `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
const cached = shapeCatalogCache.get(gameId);
if (cached?.stamp === stamp) {
return cached.value;
}
const text = fs.readFileSync(filePath, "utf8");
const value = {
filePath,
digest: sha1(text),
entries: parseCatalogCsv(text)
};
shapeCatalogCache.set(gameId, { stamp, value });
return value;
}
export function detectCatalog() {
const games = [];
for (const game of GAMES) {

View file

@ -1,5 +1,6 @@
:root {
color-scheme: light dark;
--panel-width: 360px;
--bg: #f1ead6;
--panel: rgba(255, 248, 232, 0.92);
--card: rgba(255, 255, 255, 0.58);
@ -8,13 +9,7 @@
--muted: #6e5a37;
--accent: #0d6c7d;
--accent-strong: #114f59;
--overlay-editor: #18849a;
--overlay-egg: #c67129;
--overlay-roof: #627894;
--overlay-helper-fill: rgba(77, 169, 196, 0.16);
--overlay-helper-stroke: rgba(108, 201, 228, 0.72);
--viewport: #0e1218;
--tile-border: rgba(255, 255, 255, 0.04);
--shadow: 0 18px 45px rgba(59, 40, 8, 0.16);
--font-ui: "Segoe UI Variable Text", "Aptos", "Trebuchet MS", sans-serif;
}
@ -30,7 +25,6 @@
--accent: #46a7bc;
--accent-strong: #2a7b8d;
--viewport: #06080d;
--tile-border: rgba(255, 255, 255, 0.03);
--shadow: 0 18px 45px rgba(0, 0, 0, 0.35);
}
}
@ -39,6 +33,11 @@
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
@ -51,8 +50,8 @@ body {
.shell {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
min-height: 100vh;
grid-template-columns: minmax(280px, var(--panel-width)) 12px minmax(0, 1fr);
height: 100vh;
}
.panel {
@ -61,6 +60,22 @@ body {
backdrop-filter: blur(16px);
border-right: 1px solid var(--panel-border);
box-shadow: var(--shadow);
overflow-y: auto;
overflow-x: hidden;
}
.panel-resizer {
cursor: col-resize;
background:
linear-gradient(180deg, transparent 0%, rgba(17, 79, 89, 0.3) 15%, rgba(17, 79, 89, 0.3) 85%, transparent 100%),
linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.22) 50%, transparent 100%);
}
.panel-resizer:hover,
.panel-resizer.is-dragging {
background:
linear-gradient(180deg, transparent 0%, rgba(13, 108, 125, 0.56) 15%, rgba(13, 108, 125, 0.56) 85%, transparent 100%),
linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.38) 50%, transparent 100%);
}
.panel h1 {
@ -86,7 +101,10 @@ label {
}
select,
.action-link {
.action-link,
.button-row button,
.hidden-item-button,
.tooltip-action {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(65, 48, 21, 0.18);
@ -98,7 +116,6 @@ select,
cursor: pointer;
color: white;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
text-decoration: none;
text-align: center;
}
@ -114,6 +131,26 @@ select:disabled,
gap: 8px;
}
.catalog-export-list {
display: grid;
gap: 8px;
}
.button-row button,
.hidden-item-button,
.tooltip-action {
cursor: pointer;
background: color-mix(in srgb, var(--card) 80%, white 20%);
color: var(--ink);
}
.button-row button:disabled,
.hidden-item-button:disabled,
.tooltip-action:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.toggle-grid,
.status,
.meta-panel {
@ -150,6 +187,10 @@ select:disabled,
font-weight: 700;
}
.action-link + .action-link {
margin-top: 2px;
}
.status {
min-height: 92px;
}
@ -235,9 +276,33 @@ select:disabled,
font-weight: 600;
}
.hidden-list {
display: grid;
gap: 10px;
}
.hidden-item {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(65, 48, 21, 0.08);
background: color-mix(in srgb, var(--card) 76%, transparent 24%);
}
.hidden-item-title {
font-weight: 700;
}
.hidden-item-meta {
color: var(--muted);
font-size: 0.85rem;
}
.workspace {
min-width: 0;
padding: 18px;
height: 100vh;
}
.viewport {
@ -246,84 +311,78 @@ select:disabled,
height: calc(100vh - 36px);
overflow: hidden;
border-radius: 24px;
background: radial-gradient(circle at top left, rgba(255,255,255,0.04), transparent 26%), var(--viewport);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05), var(--shadow);
background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 26%), var(--viewport);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), var(--shadow);
touch-action: none;
cursor: grab;
user-select: none;
}
.scene {
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
will-change: transform;
}
.layer {
position: absolute;
inset: 0 auto auto 0;
}
.tile {
position: absolute;
image-rendering: pixelated;
image-rendering: crisp-edges;
pointer-events: none;
}
.overlay-root {
.scene-canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay-item {
position: absolute;
pointer-events: auto;
border: 0;
padding: 0;
color: inherit;
font: inherit;
background: none;
cursor: pointer;
transition: transform 140ms ease, filter 140ms ease;
}
.viewport:not(.inspect-active) .overlay-item:hover,
.viewport:not(.inspect-active) .overlay-item:focus-visible {
transform: scale(1.05);
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.34));
}
.viewport.inspect-active .overlay-item {
pointer-events: none;
cursor: crosshair;
}
.overlay-sprite {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
user-select: none;
pointer-events: none;
}
.overlay-tooltip {
position: absolute;
z-index: 6;
max-width: 290px;
max-width: 340px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(8, 12, 18, 0.9);
color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(124, 182, 214, 0.28);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34);
pointer-events: none;
pointer-events: auto;
backdrop-filter: blur(14px);
user-select: text;
}
.overlay-tooltip[hidden] {
display: none;
}
.tooltip-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.tooltip-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tooltip-action {
width: auto;
min-width: 0;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
}
.tooltip-action svg {
display: block;
}
.tooltip-state {
display: inline-flex;
margin-top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(229, 192, 76, 0.18);
color: rgba(255, 225, 145, 0.96);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tooltip-eyebrow {
@ -414,25 +473,43 @@ select:disabled,
display: grid;
place-items: center;
padding: 24px;
color: rgba(255,255,255,0.72);
color: rgba(255, 255, 255, 0.72);
text-align: center;
}
.empty-state.is-hidden {
display: none;
opacity: 0;
pointer-events: none;
}
@media (max-width: 900px) {
@media (max-width: 960px) {
.shell {
grid-template-columns: minmax(260px, 42vw) 10px minmax(0, 1fr);
}
}
@media (max-width: 820px) {
.shell {
grid-template-columns: 1fr;
height: auto;
}
.panel {
border-right: 0;
border-bottom: 1px solid var(--panel-border);
max-height: none;
}
.panel-resizer {
display: none;
}
.workspace {
padding-top: 0;
height: auto;
}
.viewport {
height: 70vh;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,9 +8,9 @@
</head>
<body>
<div class="shell">
<aside class="panel">
<aside class="panel" id="side-panel">
<h1>Crusader Map Renderer</h1>
<p class="lede">Server-rendered tiles only. Source assets stay on the server.</p>
<p class="lede">Cache-backed atlas scene renderer. Source assets stay server-side while the browser reconstructs each map from packed sprite atlases.</p>
<form id="map-form" class="stack">
<label for="map-select">Detected maps</label>
@ -33,7 +33,13 @@
<button id="zoom-fit" type="button" disabled>Fit</button>
</div>
<div id="zoom-label" class="muted">Zoom: --</div>
<a id="download-button" class="action-link is-disabled" aria-disabled="true" href="#">Download PNG</a>
<button id="download-button" class="action-link is-disabled" type="button" aria-disabled="true">Download PNG</button>
<button id="hidden-export-button" class="action-link is-disabled" type="button" aria-disabled="true">Export Hidden JSON</button>
</div>
<div class="stack controls">
<label>Catalog CSVs</label>
<div id="catalog-export-buttons" class="catalog-export-list"></div>
</div>
<div class="stack">
@ -51,6 +57,14 @@
</div>
</div>
<div class="stack">
<label>Hidden Shapes</label>
<div id="hidden-panel" class="meta-panel">
<p id="hidden-empty" class="meta-empty">Hidden shapes will appear here and can be restored individually.</p>
<div id="hidden-list" class="hidden-list"></div>
</div>
</div>
<div class="stack">
<label>Map Metadata</label>
<div id="meta" class="meta-panel">
@ -59,13 +73,13 @@
</div>
</aside>
<div id="panel-resizer" class="panel-resizer" role="separator" aria-orientation="vertical" aria-label="Resize side panel"></div>
<main class="workspace">
<div id="viewport" class="viewport">
<div id="viewport-hint" class="viewport-hint">Drag to pan. Scroll or pinch to zoom.</div>
<div id="scene" class="scene">
<div id="active-layer" class="layer"></div>
<div id="inspect-highlight" class="inspect-highlight" hidden></div>
</div>
<canvas id="scene-canvas" class="scene-canvas"></canvas>
<div id="inspect-highlight" class="inspect-highlight" hidden></div>
<div id="overlay-tooltip" class="overlay-tooltip" hidden></div>
<div id="empty-state" class="empty-state">Choose a detected map to build and view it.</div>
</div>
@ -74,4 +88,4 @@
<script type="module" src="/app.js"></script>
</body>
</html>
</html>

View file

@ -3,7 +3,7 @@ import path from "node:path";
import { PORT, PUBLIC_ROOT } from "./config.js";
import { BuildManager } from "./lib/build-manager.js";
import { detectCatalog, getGameConfig } from "./lib/catalog.js";
import { detectCatalog, getGameConfig, getShapeCatalogFile } from "./lib/catalog.js";
const app = express();
const catalog = detectCatalog();
@ -21,10 +21,6 @@ app.post("/api/builds", async (request, response) => {
try {
const game = String(request.body?.game ?? "");
const mapId = Number.parseInt(String(request.body?.mapId ?? ""), 10);
const options = {
includeEditor: request.body?.includeEditor !== false,
includeRoofs: request.body?.includeRoofs === true
};
const gameConfig = getGameConfig(game);
if (!gameConfig) {
response.status(400).json({ error: "Unknown game id" });
@ -34,7 +30,7 @@ app.post("/api/builds", async (request, response) => {
response.status(400).json({ error: "Invalid map id" });
return;
}
const job = await builds.createOrReuseBuild(gameConfig, mapId, options);
const job = await builds.createOrReuseBuild(gameConfig, mapId);
response.status(202).json(builds.getPublicJob(job));
} catch (error) {
response.status(500).json({ error: error instanceof Error ? error.message : String(error) });
@ -61,12 +57,12 @@ app.get("/api/maps/:game/:mapId/metadata", (request, response) => {
}
});
app.get("/api/maps/:game/:mapId/overlays", (request, response) => {
app.get("/api/maps/:game/:mapId/scene", (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const overlays = builds.getOverlayData(buildId, request.params.game, mapId);
response.json(overlays);
const scene = builds.getSceneData(buildId, request.params.game, mapId);
response.json(scene);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
@ -83,74 +79,46 @@ app.get("/api/maps/:game/:mapId/inspect", (request, response) => {
}
});
app.get("/api/maps/:game/:mapId/overlays/:overlayId.webp", async (request, response) => {
app.get("/api/maps/:game/:mapId/overlays", (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const webp = await builds.renderOverlaySprite(
buildId,
request.params.game,
mapId,
request.params.overlayId,
"webp"
);
response.setHeader("Content-Type", "image/webp");
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
response.end(webp);
const overlays = builds.getOverlayData(buildId, request.params.game, mapId);
response.json(overlays);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.png", async (request, response) => {
app.get("/api/maps/:game/:mapId/atlases/:atlasId.png", (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const tileX = Number.parseInt(request.params.tileX, 10);
const tileY = Number.parseInt(request.params.tileY, 10);
if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) {
response.status(400).json({ error: "Invalid tile coordinates" });
return;
}
const png = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "png");
const atlas = builds.getAtlas(buildId, request.params.game, mapId, request.params.atlasId);
response.setHeader("Content-Type", "image/png");
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
response.end(png);
response.end(atlas);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/tiles/:tileX/:tileY.webp", async (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const tileX = Number.parseInt(request.params.tileX, 10);
const tileY = Number.parseInt(request.params.tileY, 10);
if (!Number.isInteger(tileX) || !Number.isInteger(tileY) || tileX < 0 || tileY < 0) {
response.status(400).json({ error: "Invalid tile coordinates" });
app.get("/api/catalogs/:game.csv", (request, response) => {
const filePath = getShapeCatalogFile(request.params.game);
if (!filePath) {
response.status(404).json({ error: "Unknown game id" });
return;
}
response.setHeader("Content-Type", "text/csv; charset=utf-8");
response.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
response.sendFile(path.resolve(filePath), { dotfiles: "allow" }, (error) => {
if (!error) {
return;
}
const webp = await builds.renderTile(buildId, request.params.game, mapId, tileX, tileY, "webp");
response.setHeader("Content-Type", "image/webp");
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
response.end(webp);
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/maps/:game/:mapId/download.png", async (request, response) => {
try {
const buildId = String(request.query.buildId ?? "");
const mapId = Number.parseInt(request.params.mapId, 10);
const filePath = await builds.renderFullMap(buildId, request.params.game, mapId);
response.setHeader("Content-Type", "image/png");
response.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
response.sendFile(path.resolve(filePath), { dotfiles: "allow" });
} catch (error) {
response.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
if (!response.headersSent) {
response.status(404).json({ error: "Catalog CSV not found" });
}
});
});
app.get("/api/health", (_request, response) => {