Dynamic map viewer psx
This commit is contained in:
parent
2b1f1a0191
commit
399017ab45
5 changed files with 201 additions and 63 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -44,4 +44,11 @@ bin/**
|
||||||
USECODE/REGRET/REGRET_USECODE_extracted/chunks/**
|
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
4
check_type.cjs
Normal 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
15
inspect_l0.cjs
Normal 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}`);
|
||||||
|
});
|
||||||
|
|
@ -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
0
temp_head.json
Normal file
Loading…
Add table
Add a link
Reference in a new issue