minimap.php

main 635 lines · 16.3 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2
3const ZNOTE_MINIMAP_DIR = 'engine/minimap';
4const ZNOTE_MINIMAP_META = 'engine/minimap/minimap.json';
5const ZNOTE_MINIMAP_SIGNATURE = 0x4D4D544F;
6const ZNOTE_MINIMAP_VERSION = 1;
7const ZNOTE_MINIMAP_BLOCK = 64;
8const ZNOTE_MINIMAP_BLOCK_LEN = 12288;
9const ZNOTE_MINIMAP_MAX_BLOCK = 20000;
10const ZNOTE_MINIMAP_MAX_BYTES = 16777216;
11
12class ZnoteOtmmReader
13{
14 protected string $data;
15 protected int $offset = 0;
16 protected int $length;
17
18 public function __construct(string $data)
19 {
20 $this->data = $data;
21 $this->length = strlen($data);
22 }
23
24 public function seek(int $offset): void
25 {
26 if ($offset < 0 || $offset > $this->length) {
27 throw new RuntimeException('Invalid offset inside the .otmm file.');
28 }
29 $this->offset = $offset;
30 }
31
32 public function eof(): bool
33 {
34 return $this->offset >= $this->length;
35 }
36
37 public function remaining(): int
38 {
39 return $this->length - $this->offset;
40 }
41
42 public function u8(): int
43 {
44 $this->need(1);
45 return ord($this->data[$this->offset++]);
46 }
47
48 public function u16(): int
49 {
50 $this->need(2);
51 $value = unpack('v', substr($this->data, $this->offset, 2))[1];
52 $this->offset += 2;
53 return $value;
54 }
55
56 public function u32(): int
57 {
58 $this->need(4);
59 $value = unpack('V', substr($this->data, $this->offset, 4))[1];
60 $this->offset += 4;
61 return $value;
62 }
63
64 public function string(): string
65 {
66 return $this->bytes($this->u16());
67 }
68
69 public function bytes(int $length): string
70 {
71 $this->need($length);
72 $value = substr($this->data, $this->offset, $length);
73 $this->offset += $length;
74 return $value;
75 }
76
77 protected function need(int $bytes): void
78 {
79 if ($bytes < 0 || $this->remaining() < $bytes) {
80 throw new RuntimeException('Unexpected end of the .otmm file.');
81 }
82 }
83}
84
85function minimap_root(): string
86{
87 return dirname(__DIR__, 2) . '/' . ZNOTE_MINIMAP_DIR;
88}
89
90function minimap_meta_path(): string
91{
92 return dirname(__DIR__, 2) . '/' . ZNOTE_MINIMAP_META;
93}
94
95function minimap_data()
96{
97 static $data = null;
98 if ($data !== null) {
99 return $data;
100 }
101
102 $data = false;
103 $file = minimap_meta_path();
104 if (!is_file($file)) {
105 return $data;
106 }
107
108 $decoded = json_decode((string)file_get_contents($file), true);
109 if (!is_array($decoded) || empty($decoded['floors'])) {
110 return $data;
111 }
112
113 ksort($decoded['floors'], SORT_NUMERIC);
114 $data = $decoded;
115
116 return $data;
117}
118
119function minimap_available(): bool
120{
121 return minimap_data() !== false;
122}
123
124function minimap_color(int $color): array
125{
126 if ($color >= 0 && $color < 216) {
127 return array(
128 (int)(floor($color / 36) * 51),
129 (int)(floor(($color % 36) / 6) * 51),
130 (int)(($color % 6) * 51)
131 );
132 }
133
134 return array(0, 0, 0);
135}
136
137function minimap_tile_image(string $raw, bool &$hasVisible)
138{
139 $image = imagecreatetruecolor(ZNOTE_MINIMAP_BLOCK, ZNOTE_MINIMAP_BLOCK);
140 imagealphablending($image, false);
141 imagesavealpha($image, true);
142 imagefill($image, 0, 0, imagecolorallocatealpha($image, 0, 0, 0, 127));
143
144 $cache = array();
145 $hasVisible = false;
146
147 for ($index = 0; $index < ZNOTE_MINIMAP_BLOCK * ZNOTE_MINIMAP_BLOCK; $index++) {
148 $offset = $index * 3;
149 $flags = ord($raw[$offset]);
150 $color = ord($raw[$offset + 1]);
151
152 if (!($flags & 1) || ($flags & 8) || $color === 255) {
153 continue;
154 }
155 if (!isset($cache[$color])) {
156 list($r, $g, $b) = minimap_color($color);
157 $cache[$color] = imagecolorallocatealpha($image, $r, $g, $b, 0);
158 }
159
160 imagesetpixel($image, $index % ZNOTE_MINIMAP_BLOCK, (int)floor($index / ZNOTE_MINIMAP_BLOCK), $cache[$color]);
161 $hasVisible = true;
162 }
163
164 return $image;
165}
166
167function minimap_delete(): void
168{
169 foreach (glob(minimap_root() . '/*.png') ?: array() as $tile) {
170 @unlink($tile);
171 }
172 @unlink(minimap_meta_path());
173}
174
175function minimap_import(string $tmpFile, string $originalName, ?string &$error = null): bool
176{
177 $error = null;
178
179 if (strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION)) !== 'otmm') {
180 $error = 'Only .otmm minimap files are accepted.';
181 return false;
182 }
183 if (!is_file($tmpFile) || filesize($tmpFile) < 16) {
184 $error = 'The uploaded file is empty or unreadable.';
185 return false;
186 }
187 if (filesize($tmpFile) > ZNOTE_MINIMAP_MAX_BYTES) {
188 $error = 'The .otmm file must be ' . (int)(ZNOTE_MINIMAP_MAX_BYTES / 1048576) . ' MB or smaller.';
189 return false;
190 }
191 if (!function_exists('gzuncompress') || !function_exists('imagecreatetruecolor')) {
192 $error = 'PHP needs the zlib and GD extensions to convert an .otmm minimap.';
193 return false;
194 }
195
196 $root = minimap_root();
197 if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
198 $error = 'Could not create ' . ZNOTE_MINIMAP_DIR . '/. Give the web server write access.';
199 return false;
200 }
201 if (!is_writable($root)) {
202 $error = ZNOTE_MINIMAP_DIR . '/ is not writable by the web server.';
203 return false;
204 }
205
206 $data = file_get_contents($tmpFile);
207 if ($data === false) {
208 $error = 'Could not read the uploaded file.';
209 return false;
210 }
211
212 @set_time_limit(600);
213
214 $floors = array();
215 $tiles = 0;
216
217 try {
218 $reader = new ZnoteOtmmReader($data);
219
220 if ($reader->u32() !== ZNOTE_MINIMAP_SIGNATURE) {
221 $error = 'This is not a valid OTMM minimap file.';
222 return false;
223 }
224
225 $start = $reader->u16();
226 $version = $reader->u16();
227 $reader->u32();
228
229 if ($version !== ZNOTE_MINIMAP_VERSION) {
230 $error = 'Unsupported OTMM minimap version.';
231 return false;
232 }
233
234 $reader->string();
235 $reader->seek($start);
236
237 minimap_delete();
238
239 $blocks = 0;
240
241 while (!$reader->eof()) {
242 if (++$blocks > ZNOTE_MINIMAP_MAX_BLOCK) {
243 minimap_delete();
244 $error = 'The .otmm minimap contains too many blocks.';
245 return false;
246 }
247 if ($reader->remaining() < 7) {
248 break;
249 }
250
251 $x = $reader->u16();
252 $y = $reader->u16();
253 $z = $reader->u8();
254
255 if ($x >= 65535 || $y >= 65535 || $z > 15) {
256 break;
257 }
258
259 $length = $reader->u16();
260 if ($length <= 0 || $length > $reader->remaining()) {
261 minimap_delete();
262 $error = 'The .otmm minimap contains a corrupt block.';
263 return false;
264 }
265
266 $raw = @gzuncompress($reader->bytes($length), ZNOTE_MINIMAP_BLOCK_LEN);
267 if (!is_string($raw) || strlen($raw) !== ZNOTE_MINIMAP_BLOCK_LEN) {
268 continue;
269 }
270
271 $hasVisible = false;
272 $image = minimap_tile_image($raw, $hasVisible);
273
274 if (!$hasVisible) {
275 imagedestroy($image);
276 continue;
277 }
278
279 imagepng($image, $root . '/z' . $z . '-' . $x . '-' . $y . '.png', 6);
280 imagedestroy($image);
281 $tiles++;
282
283 if (!isset($floors[$z])) {
284 $floors[$z] = array(
285 'min_x' => $x,
286 'min_y' => $y,
287 'max_x' => $x + 63,
288 'max_y' => $y + 63,
289 'tiles' => array()
290 );
291 }
292
293 $floors[$z]['min_x'] = min($floors[$z]['min_x'], $x);
294 $floors[$z]['min_y'] = min($floors[$z]['min_y'], $y);
295 $floors[$z]['max_x'] = max($floors[$z]['max_x'], $x + 63);
296 $floors[$z]['max_y'] = max($floors[$z]['max_y'], $y + 63);
297 $floors[$z]['tiles'][] = array($x, $y);
298 }
299 } catch (Throwable $e) {
300 minimap_delete();
301 $error = 'Could not read the .otmm minimap: ' . $e->getMessage();
302 return false;
303 }
304
305 if (!$floors) {
306 minimap_delete();
307 $error = 'No visible minimap tiles were found in this .otmm file.';
308 return false;
309 }
310
311 ksort($floors, SORT_NUMERIC);
312
313 $prepared = array();
314 foreach ($floors as $z => $floor) {
315 $prepared[(int)$z] = array(
316 'z' => (int)$z,
317 'x' => (int)$floor['min_x'],
318 'y' => (int)$floor['min_y'],
319 'w' => max(64, (int)($floor['max_x'] - $floor['min_x'] + 1)),
320 'h' => max(64, (int)($floor['max_y'] - $floor['min_y'] + 1)),
321 't' => $floor['tiles']
322 );
323 }
324
325 $meta = array(
326 'date' => time(),
327 'source' => substr((string)preg_replace('/[^A-Za-z0-9._-]/', '', basename($originalName)), 0, 80),
328 'tiles' => $tiles,
329 'floors' => $prepared
330 );
331
332 if (file_put_contents(minimap_meta_path(), json_encode($meta)) === false) {
333 minimap_delete();
334 $error = 'Could not write ' . ZNOTE_MINIMAP_META . '.';
335 return false;
336 }
337
338 return true;
339}
340
341
342function minimap_was_rendered(): bool
343{
344 return !empty($GLOBALS['__znote_minimap_rendered']);
345}
346
347function minimap_render(string $baseUrl = '', bool $withTitle = true): void
348{
349 if (minimap_was_rendered()) {
350 return;
351 }
352
353 $data = minimap_data();
354 if ($data === false) {
355 return;
356 }
357
358 $GLOBALS['__znote_minimap_rendered'] = true;
359
360 $floors = array();
361 foreach ($data['floors'] as $floor) {
362 $floors[] = array(
363 'z' => (int)$floor['z'],
364 'x' => (int)$floor['x'],
365 'y' => (int)$floor['y'],
366 't' => $floor['t']
367 );
368 }
369
370 $payload = array(
371 'base' => (($baseUrl !== '') ? rtrim($baseUrl, '/') . '/' : '') . ZNOTE_MINIMAP_DIR . '/',
372 'v' => (int)($data['date'] ?? 0),
373 'start' => 7,
374 'floors' => $floors
375 );
376 ?>
377 <div class="znote-minimap" id="znoteMinimap">
378 <?php if ($withTitle): ?><h2 class="znote-minimap-title">World map</h2><?php endif; ?>
379 <div class="znote-minimap-stage">
380 <div class="znote-minimap-canvas"></div>
381 <div class="znote-minimap-arrows">
382 <button type="button" class="znote-minimap-btn" data-minimap="floor-up" title="Floor up">&#9650;</button>
383 <span class="znote-minimap-level">Floor <b>7</b></span>
384 <button type="button" class="znote-minimap-btn" data-minimap="floor-down" title="Floor down">&#9660;</button>
385 </div>
386 <div class="znote-minimap-zoom">
387 <button type="button" class="znote-minimap-btn" data-minimap="zoom-in" title="Zoom in">+</button>
388 <button type="button" class="znote-minimap-btn" data-minimap="zoom-out" title="Zoom out">&minus;</button>
389 </div>
390 </div>
391 <p class="znote-minimap-hint">Drag to move, scroll or use +/&minus; to zoom, and change floor with the arrows.</p>
392 </div>
393 <style>
394 .znote-minimap { margin: 0 0 20px; }
395 .znote-minimap-title { margin: 0 0 8px; }
396 .znote-minimap-stage {
397 position: relative;
398 height: 460px;
399 overflow: hidden;
400 cursor: grab;
401 touch-action: none;
402 background: var(--bg-default, rgb(15,17,20));
403 border: 1px solid var(--border, rgb(19,20,23));
404 }
405 .znote-minimap-stage.is-dragging { cursor: grabbing; }
406 .znote-minimap-canvas { position: absolute; left: 0; top: 0; right: 0; bottom: 0; }
407 /* max-width/height come back explicitly: a CSS reset like Tailwind's
408 preflight sets img{max-width:100%;height:auto}, which would collapse
409 every tile the moment this sits in a themed page. */
410 .znote-minimap-canvas img {
411 position: absolute;
412 max-width: none;
413 max-height: none;
414 min-width: 0;
415 min-height: 0;
416 image-rendering: pixelated;
417 user-select: none;
418 pointer-events: none;
419 }
420 .znote-minimap-arrows,
421 .znote-minimap-zoom {
422 position: absolute;
423 display: flex;
424 flex-direction: column;
425 gap: 4px;
426 z-index: 2;
427 }
428 .znote-minimap-arrows { top: 10px; right: 10px; align-items: center; }
429 .znote-minimap-zoom { bottom: 10px; right: 10px; }
430 .znote-minimap-btn {
431 width: 32px;
432 height: 32px;
433 padding: 0;
434 line-height: 1;
435 font-size: 14px;
436 cursor: pointer;
437 color: var(--font-color, rgb(155,162,177));
438 background: var(--primary, rgb(30,33,40));
439 border: 1px solid var(--border, rgb(19,20,23));
440 }
441 .znote-minimap-btn:hover { color: var(--anchor-hover, #e79424); }
442 .znote-minimap-level {
443 padding: 2px 6px;
444 font-size: 11px;
445 white-space: nowrap;
446 color: var(--font-color, rgb(155,162,177));
447 background: var(--secondary, rgb(25,28,33));
448 border: 1px solid var(--border, rgb(19,20,23));
449 }
450 .znote-minimap-hint { margin: 6px 0 0; font-size: 12px; opacity: .7; }
451 </style>
452 <script>
453 (function () {
454 var data = <?php echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
455
456 function init() {
457 var viewer = document.getElementById('znoteMinimap');
458 if (!viewer || viewer.dataset.ready) return;
459 viewer.dataset.ready = '1';
460
461 if (!data || !data.floors || !data.floors.length) return;
462
463 var stage = viewer.querySelector('.znote-minimap-stage');
464 var canvas = viewer.querySelector('.znote-minimap-canvas');
465 var label = viewer.querySelector('.znote-minimap-level b');
466 if (!stage || !canvas) return;
467
468 var TILE = 64;
469
470 var index = 0;
471 for (var i = 0; i < data.floors.length; i++) {
472 if (data.floors[i].z === data.start) { index = i; break; }
473 }
474
475 var scale = 1, panX = 0, panY = 0;
476 var dragging = false, startX = 0, startY = 0;
477 var mounted = {};
478
479 function floorData() { return data.floors[index]; }
480
481 function focusPoint() {
482 var floor = floorData();
483 if (!floor.t.length) return { x: 0, y: 0 };
484
485 var xs = [], ys = [];
486 for (var i = 0; i < floor.t.length; i++) {
487 xs.push(floor.t[i][0]);
488 ys.push(floor.t[i][1]);
489 }
490 xs.sort(function (a, b) { return a - b; });
491 ys.sort(function (a, b) { return a - b; });
492
493 return {
494 x: xs[Math.floor(xs.length / 2)] - floor.x + (TILE / 2),
495 y: ys[Math.floor(ys.length / 2)] - floor.y + (TILE / 2)
496 };
497 }
498
499 function center() {
500 var point = focusPoint();
501 panX = Math.round((stage.clientWidth / 2) - point.x * scale);
502 panY = Math.round((stage.clientHeight / 2) - point.y * scale);
503 }
504
505 // Tiles are placed straight into stage space, one node per visible tile.
506 // Panning a single huge element instead would ask the browser for a
507 // layer far past its maximum texture size - a 32000px floor then paints
508 // as nothing at all.
509 function render() {
510 var floor = floorData();
511 var width = stage.clientWidth;
512 var height = stage.clientHeight;
513 var margin = TILE * 2;
514 var seen = {};
515
516 for (var i = 0; i < floor.t.length; i++) {
517 var fx = floor.t[i][0] - floor.x;
518 var fy = floor.t[i][1] - floor.y;
519
520 var left = Math.round(fx * scale + panX);
521 var top = Math.round(fy * scale + panY);
522 var w = Math.round((fx + TILE) * scale + panX) - left;
523 var h = Math.round((fy + TILE) * scale + panY) - top;
524
525 if (left > width + margin || top > height + margin || left + w < -margin || top + h < -margin) {
526 continue;
527 }
528
529 seen[i] = true;
530 var img = mounted[i];
531
532 if (!img) {
533 img = document.createElement('img');
534 img.alt = '';
535 img.draggable = false;
536 img.src = data.base + 'z' + floor.z + '-' + floor.t[i][0] + '-' + floor.t[i][1] + '.png?' + data.v;
537 canvas.appendChild(img);
538 mounted[i] = img;
539 }
540
541 img.style.left = left + 'px';
542 img.style.top = top + 'px';
543 img.style.width = w + 'px';
544 img.style.height = h + 'px';
545 }
546
547 for (var key in mounted) {
548 if (!seen[key]) {
549 canvas.removeChild(mounted[key]);
550 delete mounted[key];
551 }
552 }
553 }
554
555 function clear() {
556 canvas.textContent = '';
557 mounted = {};
558 }
559
560 function showFloor(next) {
561 index = (next + data.floors.length) % data.floors.length;
562 if (label) label.textContent = floorData().z;
563 clear();
564 center();
565 render();
566 }
567
568 function zoom(delta) {
569 var next = Math.max(0.25, Math.min(4, scale + delta));
570 if (next === scale) return;
571
572 var cx = stage.clientWidth / 2, cy = stage.clientHeight / 2;
573 panX = Math.round(cx - (cx - panX) * (next / scale));
574 panY = Math.round(cy - (cy - panY) * (next / scale));
575 scale = next;
576 clear();
577 render();
578 }
579
580 // Never capture the pointer for a press that started on a control:
581 // capturing retargets the following pointerup, so the browser fires
582 // the click on the stage instead of the button and it does nothing.
583 stage.addEventListener('pointerdown', function (e) {
584 if (e.target && e.target.closest && e.target.closest('[data-minimap]')) return;
585
586 dragging = true;
587 startX = e.clientX - panX;
588 startY = e.clientY - panY;
589 stage.setPointerCapture(e.pointerId);
590 stage.classList.add('is-dragging');
591 });
592 stage.addEventListener('pointermove', function (e) {
593 if (!dragging) return;
594 panX = e.clientX - startX;
595 panY = e.clientY - startY;
596 render();
597 });
598 stage.addEventListener('pointerup', function (e) {
599 dragging = false;
600 stage.classList.remove('is-dragging');
601 try { stage.releasePointerCapture(e.pointerId); } catch (err) {}
602 });
603 stage.addEventListener('wheel', function (e) {
604 e.preventDefault();
605 zoom(e.deltaY < 0 ? 0.25 : -0.25);
606 }, { passive: false });
607
608 viewer.addEventListener('click', function (e) {
609 var button = e.target.closest('[data-minimap]');
610 if (!button) return;
611
612 var action = button.getAttribute('data-minimap');
613 if (action === 'zoom-in') zoom(0.25);
614 else if (action === 'zoom-out') zoom(-0.25);
615 else if (action === 'floor-up') showFloor(index - 1);
616 else if (action === 'floor-down') showFloor(index + 1);
617 });
618
619 window.addEventListener('resize', function () { center(); render(); });
620
621 if (label) label.textContent = floorData().z;
622 center();
623 render();
624 }
625
626 if (document.readyState === 'loading') {
627 document.addEventListener('DOMContentLoaded', init);
628 } else {
629 init();
630 }
631 })();
632 </script>
633 <?php
634}
635
Top