Dynamic map viewer psx

This commit is contained in:
MaddoScientisto 2026-04-18 16:53:43 +02:00
commit 399017ab45
5 changed files with 201 additions and 63 deletions

7
.gitignore vendored
View file

@ -45,3 +45,10 @@ USECODE/REGRET/REGRET_USECODE_extracted/chunks/**
exports/** exports/**
out/** out/**
binary/** binary/**
psx-map-exporter/.output-render/**
# JavaScript / Node
**/node_modules/**
**/dist/**
**/.vite/**
*.log

4
check_type.cjs Normal file
View file

@ -0,0 +1,4 @@
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('psx-map-exporter/.output-render/L0/auto/L0.json', 'utf8'));
console.log('Type of data:', Array.isArray(data) ? 'Array' : typeof data);
if (!Array.isArray(data)) console.log('Keys:', Object.keys(data));

15
inspect_l0.cjs Normal file
View file

@ -0,0 +1,15 @@
const fs = require('fs');
const path = require('path');
const jsonPath = 'psx-map-exporter/.output-render/L0/auto/L0.json';
const cacheDir = 'psx-map-exporter/.cache/L0/sprites';
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const items = (data.items || []).filter(item => item.bundleAbsoluteOffset !== undefined && item.frameIndex !== undefined && item.width > 0).slice(0, 5);
items.forEach(item => {
const bundleHex = item.bundleAbsoluteOffset.toString(16).padStart(8, '0');
const frameIdx = item.frameIndex.toString().padStart(3, '0');
const fileName = `bundle_${bundleHex}/frame_${frameIdx}.png`;
const fullPath = path.join(cacheDir, fileName);
const exists = fs.existsSync(fullPath);
console.log(`recordIndex: ${item.recordIndex}, bundleAbsoluteOffset: ${item.bundleAbsoluteOffset}, frameIndex: ${item.frameIndex}, width: ${item.width}, height: ${item.height}`);
console.log(`File: ${fileName}, Exists: ${exists}`);
});

View file

@ -1,8 +1,12 @@
<script setup> <script setup>
import { computed, onMounted, ref, shallowRef, watch } from 'vue'; import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
import ItemPanel from './ItemPanel.vue'; import ItemPanel from './ItemPanel.vue';
const PADDING = 16; const PADDING = 16;
// Match psx-map-exporter/src/render.js DEFAULT_BACKGROUND. The exporter still
// produces a baked .png for offline reference, but the viewer composites each
// sprite live so hide/filter operations no longer carve holes out of the map.
const BACKGROUND = 'rgb(18, 18, 18)';
const indexData = ref(null); const indexData = ref(null);
const indexError = ref(null); const indexError = ref(null);
@ -12,10 +16,11 @@ const scene = shallowRef(null);
const sceneError = ref(null); const sceneError = ref(null);
const loading = ref(false); const loading = ref(false);
const imageRef = ref(null); const canvasRef = ref(null);
const containerRef = ref(null); const containerRef = ref(null);
const imageNaturalWidth = ref(0); // Sprite image cache shared across redraws. Keyed by mapStem|bundleAbsHex|frameIdx
const imageNaturalHeight = ref(0); // so swapping maps does not invalidate sprites still bound by other items.
const spriteCache = new Map();
const zoom = ref(1); const zoom = ref(1);
const pan = ref({ x: 0, y: 0 }); const pan = ref({ x: 0, y: 0 });
@ -28,10 +33,7 @@ const filterBundleHex = ref('');
const hovered = shallowRef(null); const hovered = shallowRef(null);
const selected = shallowRef(null); const selected = shallowRef(null);
// Set of recordIndex values the user has manually hidden via shift+click. We // Set of recordIndex values the user has manually hidden via shift+click.
// rebuild the visible item list against this set so painted sprites and SVG
// hitboxes both disappear together. Hidden items are restored via the
// "Show hidden" button or by selecting them in the unfiltered "Hidden list".
const hiddenIds = ref(new Set()); const hiddenIds = ref(new Set());
const hiddenList = computed(() => { const hiddenList = computed(() => {
if (!scene.value || hiddenIds.value.size === 0) return []; if (!scene.value || hiddenIds.value.size === 0) return [];
@ -92,17 +94,18 @@ async function loadScene() {
scene.value = null; scene.value = null;
selected.value = null; selected.value = null;
hovered.value = null; hovered.value = null;
hiddenIds.value = new Set();
try { try {
const base = `/render/${selectedMap.value}/${selectedVariant.value}/${selectedMap.value}`; const base = `/render/${selectedMap.value}/${selectedVariant.value}/${selectedMap.value}`;
const json = await fetch(`${base}.json`).then((r) => { const json = await fetch(`${base}.json`).then((r) => {
if (!r.ok) throw new Error('Scene JSON HTTP ' + r.status); if (!r.ok) throw new Error('Scene JSON HTTP ' + r.status);
return r.json(); return r.json();
}); });
scene.value = { scene.value = { json, mapStem: selectedMap.value };
json, // Eagerly preload all unique sprite frames referenced by this scene so
pngUrl: `${base}.png`, // the first canvas paint shows the full map instead of a half-loaded
labelsPngUrl: `${base}_labels.png`, // jumble. drawScene() is then called once all are settled.
}; await preloadSprites(json, selectedMap.value);
} catch (error) { } catch (error) {
sceneError.value = error.message; sceneError.value = error.message;
} finally { } finally {
@ -112,22 +115,135 @@ async function loadScene() {
watch([selectedMap, selectedVariant], loadScene); watch([selectedMap, selectedVariant], loadScene);
function onImageLoad() { function spriteKey(mapStem, bundleAbsoluteOffset, frameIndex) {
if (!imageRef.value) return; return `${mapStem}|${bundleAbsoluteOffset.toString(16)}|${frameIndex}`;
imageNaturalWidth.value = imageRef.value.naturalWidth; }
imageNaturalHeight.value = imageRef.value.naturalHeight; function spriteUrl(mapStem, bundleAbsoluteOffset, frameIndex) {
fitToContainer(); const bundleHex = bundleAbsoluteOffset.toString(16).padStart(8, '0');
const frameStr = String(frameIndex).padStart(3, '0');
return `/sprites/${mapStem}/bundle_${bundleHex}/frame_${frameStr}.png`;
}
function loadSprite(mapStem, bundleAbsoluteOffset, frameIndex) {
const key = spriteKey(mapStem, bundleAbsoluteOffset, frameIndex);
let entry = spriteCache.get(key);
if (entry) return entry;
entry = new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// Replace the Promise with the resolved Image so drawScene's fast path
// (synchronous cache hit) sees a real HTMLImageElement instead of a
// Promise. The Promise is still returned from this call for any awaiter.
spriteCache.set(key, img);
resolve(img);
};
img.onerror = () => {
spriteCache.set(key, null); // Cache the miss so we do not retry forever
resolve(null);
};
img.src = spriteUrl(mapStem, bundleAbsoluteOffset, frameIndex);
});
spriteCache.set(key, entry);
return entry;
}
async function preloadSprites(json, mapStem) {
const seen = new Set();
const tasks = [];
for (const it of json.items) {
if (it.placeholder) continue;
if (!Number.isInteger(it.bundleAbsoluteOffset) || !Number.isInteger(it.frameIndex)) continue;
const key = spriteKey(mapStem, it.bundleAbsoluteOffset, it.frameIndex);
if (seen.has(key)) continue;
seen.add(key);
tasks.push(loadSprite(mapStem, it.bundleAbsoluteOffset, it.frameIndex));
}
await Promise.all(tasks);
}
const canvasWidth = computed(() => {
if (!scene.value) return 0;
return scene.value.json.renderWidth ?? 0;
});
const canvasHeight = computed(() => {
if (!scene.value) return 0;
return scene.value.json.renderHeight ?? 0;
});
function drawPlaceholder(ctx, dstX, dstY, w, h) {
const half = Math.max(2, Math.floor(Math.min(w, h) / 2));
const cx = Math.round(dstX + w / 2);
const cy = Math.round(dstY + h / 2);
ctx.beginPath();
ctx.moveTo(cx, cy - half);
ctx.lineTo(cx + half, cy);
ctx.lineTo(cx, cy + half);
ctx.lineTo(cx - half, cy);
ctx.closePath();
ctx.fillStyle = 'rgb(220, 60, 200)';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgb(90, 0, 110)';
ctx.stroke();
}
function drawScene() {
if (!scene.value || !canvasRef.value) return;
const cv = canvasRef.value;
const w = canvasWidth.value;
const h = canvasHeight.value;
if (cv.width !== w) cv.width = w;
if (cv.height !== h) cv.height = h;
const ctx = cv.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = BACKGROUND;
ctx.fillRect(0, 0, w, h);
const bounds = scene.value.json.bounds;
const mapStem = scene.value.mapStem;
for (const item of visibleItems()) {
const dx = (item.drawX - bounds.minX) + PADDING;
const dy = (item.drawY - bounds.minY) + PADDING;
if (item.placeholder || !Number.isInteger(item.bundleAbsoluteOffset)) {
drawPlaceholder(ctx, dx, dy, item.width, item.height);
continue;
}
const key = spriteKey(mapStem, item.bundleAbsoluteOffset, item.frameIndex);
let cached = spriteCache.get(key);
if (cached === undefined) {
// Not yet requested: kick a load and skip this paint. The load
// resolution will trigger a redraw via the .then handler below.
cached = loadSprite(mapStem, item.bundleAbsoluteOffset, item.frameIndex);
}
// Only synchronous cache hits are drawn this pass. Misses kick off a load
// and trigger a redraw when complete; this keeps the paint loop simple
// and avoids partial draws when scrolling.
if (cached && typeof cached.then === 'function') {
cached.then((img) => {
if (img && scene.value?.mapStem === mapStem) drawScene();
});
continue;
}
const img = cached;
if (!img) continue;
if (item.flipped) {
ctx.save();
ctx.translate(dx + item.width, dy);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0);
ctx.restore();
} else {
ctx.drawImage(img, dx, dy);
}
}
} }
function fitToContainer() { function fitToContainer() {
if (!containerRef.value || !imageNaturalWidth.value) return; if (!containerRef.value || !canvasWidth.value) return;
const c = containerRef.value.getBoundingClientRect(); const c = containerRef.value.getBoundingClientRect();
const sx = c.width / imageNaturalWidth.value; const sx = c.width / canvasWidth.value;
const sy = c.height / imageNaturalHeight.value; const sy = c.height / canvasHeight.value;
const z = Math.min(sx, sy) * 0.95; const z = Math.min(sx, sy) * 0.95;
pan.value = { pan.value = {
x: (c.width - imageNaturalWidth.value * z) / 2, x: (c.width - canvasWidth.value * z) / 2,
y: (c.height - imageNaturalHeight.value * z) / 2, y: (c.height - canvasHeight.value * z) / 2,
}; };
zoom.value = z; zoom.value = z;
} }
@ -178,10 +294,9 @@ function onClick(event) {
const px = (event.clientX - rect.left - pan.value.x) / zoom.value; const px = (event.clientX - rect.left - pan.value.x) / zoom.value;
const py = (event.clientY - rect.top - pan.value.y) / zoom.value; const py = (event.clientY - rect.top - pan.value.y) / zoom.value;
const item = pickItem(px, py); const item = pickItem(px, py);
// Shift+click hides the picked item (overlay rect AND painted sprite still // Shift+click hides the picked item from the live canvas redraw, so the
// shows since the .png is baked, but the SVG hitbox vanishes so you can // sprite vanishes instead of being masked over and you can see anything
// click through to whatever is underneath; combine with the hidden list // underneath. Clear via the "Show hidden" / "restore all" buttons.
// panel to restore visibility).
if (event.shiftKey && item) { if (event.shiftKey && item) {
hideItem(item); hideItem(item);
return; return;
@ -251,17 +366,28 @@ const overlayBoxes = computed(() => {
return visibleItems().map((it) => ({ item: it, rect: itemPixelRect(it) })); return visibleItems().map((it) => ({ item: it, rect: itemPixelRect(it) }));
}); });
// Black-out rects for shift+click-hidden items: the rendered .png is baked, // The scene canvas is inside the `v-else-if="scene"` template branch, which
// so we paint an opaque rectangle over each hidden sprite using the same // does not mount until `loading` flips back to false. Painting during
// background colour the renderer used (DEFAULT_BACKGROUND in render.js = // `loadScene()` therefore races the DOM and leaves the canvas blank while the
// rgb(18,18,18)). This lets the user see whatever real sprite lives behind // SVG hitbox overlay still appears. Paint only after the scene branch exists.
// the obscuring one without re-running the exporter. watch(
const hiddenMasks = computed(() => { [scene, loading, canvasWidth, canvasHeight],
if (!scene.value || hiddenIds.value.size === 0) return []; async ([nextScene, isLoading, width, height]) => {
return scene.value.json.items if (!nextScene || isLoading || !width || !height) return;
.filter((it) => hiddenIds.value.has(it.recordIndex)) await nextTick();
.map((it) => ({ item: it, rect: itemPixelRect(it) })); fitToContainer();
}); drawScene();
},
{ immediate: true },
);
// Trigger a canvas repaint whenever any input that affects drawing changes.
// `visibleItems()` is not wrapped in a computed (it returns a fresh array),
// so we watch the underlying inputs explicitly.
watch(
[hiddenIds, showOnlyLayer, filterTypeHex, filterBundleHex],
() => { drawScene(); },
);
function strokeFor(item) { function strokeFor(item) {
if (item === selected.value) return '#4ea1ff'; if (item === selected.value) return '#4ea1ff';
@ -320,33 +446,19 @@ onMounted(loadIndex);
<div v-else-if="sceneError" class="error">{{ sceneError }}</div> <div v-else-if="sceneError" class="error">{{ sceneError }}</div>
<div v-else-if="loading" class="muted center">Loading</div> <div v-else-if="loading" class="muted center">Loading</div>
<div v-else-if="scene" class="stage" :style="transformStyle"> <div v-else-if="scene" class="stage" :style="transformStyle">
<img <canvas
ref="imageRef" ref="canvasRef"
:src="scene.pngUrl" class="map-canvas"
@load="onImageLoad" :width="canvasWidth"
draggable="false" :height="canvasHeight"
class="map-img"
/> />
<svg <svg
v-if="imageNaturalWidth" v-if="canvasWidth"
class="overlay" class="overlay"
:width="imageNaturalWidth" :width="canvasWidth"
:height="imageNaturalHeight" :height="canvasHeight"
:viewBox="`0 0 ${imageNaturalWidth} ${imageNaturalHeight}`" :viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
> >
<rect
v-for="entry in hiddenMasks"
:key="'mask-' + entry.item.recordIndex"
:x="entry.rect.x"
:y="entry.rect.y"
:width="entry.rect.w"
:height="entry.rect.h"
fill="rgb(18,18,18)"
stroke="rgba(255, 80, 80, 0.6)"
stroke-width="1"
vector-effect="non-scaling-stroke"
stroke-dasharray="4 2"
/>
<rect <rect
v-for="entry in overlayBoxes" v-for="entry in overlayBoxes"
:key="entry.item.recordIndex" :key="entry.item.recordIndex"
@ -407,7 +519,7 @@ onMounted(loadIndex);
} }
.canvas:active { cursor: grabbing; } .canvas:active { cursor: grabbing; }
.stage { position: absolute; top: 0; left: 0; } .stage { position: absolute; top: 0; left: 0; }
.map-img { display: block; image-rendering: pixelated; user-select: none; -webkit-user-drag: none; } .map-canvas { display: block; image-rendering: pixelated; user-select: none; -webkit-user-drag: none; background: rgb(18, 18, 18); }
.overlay { position: absolute; top: 0; left: 0; pointer-events: none; } .overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
.sidebar { .sidebar {

0
temp_head.json Normal file
View file