Dynamic map viewer psx
This commit is contained in:
parent
2b1f1a0191
commit
399017ab45
5 changed files with 201 additions and 63 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -45,3 +45,10 @@ USECODE/REGRET/REGRET_USECODE_extracted/chunks/**
|
|||
exports/**
|
||||
out/**
|
||||
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>
|
||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import ItemPanel from './ItemPanel.vue';
|
||||
|
||||
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 indexError = ref(null);
|
||||
|
|
@ -12,10 +16,11 @@ const scene = shallowRef(null);
|
|||
const sceneError = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const imageRef = ref(null);
|
||||
const canvasRef = ref(null);
|
||||
const containerRef = ref(null);
|
||||
const imageNaturalWidth = ref(0);
|
||||
const imageNaturalHeight = ref(0);
|
||||
// Sprite image cache shared across redraws. Keyed by mapStem|bundleAbsHex|frameIdx
|
||||
// so swapping maps does not invalidate sprites still bound by other items.
|
||||
const spriteCache = new Map();
|
||||
|
||||
const zoom = ref(1);
|
||||
const pan = ref({ x: 0, y: 0 });
|
||||
|
|
@ -28,10 +33,7 @@ const filterBundleHex = ref('');
|
|||
|
||||
const hovered = shallowRef(null);
|
||||
const selected = shallowRef(null);
|
||||
// Set of recordIndex values the user has manually hidden via shift+click. We
|
||||
// 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".
|
||||
// Set of recordIndex values the user has manually hidden via shift+click.
|
||||
const hiddenIds = ref(new Set());
|
||||
const hiddenList = computed(() => {
|
||||
if (!scene.value || hiddenIds.value.size === 0) return [];
|
||||
|
|
@ -92,17 +94,18 @@ async function loadScene() {
|
|||
scene.value = null;
|
||||
selected.value = null;
|
||||
hovered.value = null;
|
||||
hiddenIds.value = new Set();
|
||||
try {
|
||||
const base = `/render/${selectedMap.value}/${selectedVariant.value}/${selectedMap.value}`;
|
||||
const json = await fetch(`${base}.json`).then((r) => {
|
||||
if (!r.ok) throw new Error('Scene JSON HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
scene.value = {
|
||||
json,
|
||||
pngUrl: `${base}.png`,
|
||||
labelsPngUrl: `${base}_labels.png`,
|
||||
};
|
||||
scene.value = { json, mapStem: selectedMap.value };
|
||||
// Eagerly preload all unique sprite frames referenced by this scene so
|
||||
// the first canvas paint shows the full map instead of a half-loaded
|
||||
// jumble. drawScene() is then called once all are settled.
|
||||
await preloadSprites(json, selectedMap.value);
|
||||
} catch (error) {
|
||||
sceneError.value = error.message;
|
||||
} finally {
|
||||
|
|
@ -112,22 +115,135 @@ async function loadScene() {
|
|||
|
||||
watch([selectedMap, selectedVariant], loadScene);
|
||||
|
||||
function onImageLoad() {
|
||||
if (!imageRef.value) return;
|
||||
imageNaturalWidth.value = imageRef.value.naturalWidth;
|
||||
imageNaturalHeight.value = imageRef.value.naturalHeight;
|
||||
fitToContainer();
|
||||
function spriteKey(mapStem, bundleAbsoluteOffset, frameIndex) {
|
||||
return `${mapStem}|${bundleAbsoluteOffset.toString(16)}|${frameIndex}`;
|
||||
}
|
||||
function spriteUrl(mapStem, bundleAbsoluteOffset, frameIndex) {
|
||||
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() {
|
||||
if (!containerRef.value || !imageNaturalWidth.value) return;
|
||||
if (!containerRef.value || !canvasWidth.value) return;
|
||||
const c = containerRef.value.getBoundingClientRect();
|
||||
const sx = c.width / imageNaturalWidth.value;
|
||||
const sy = c.height / imageNaturalHeight.value;
|
||||
const sx = c.width / canvasWidth.value;
|
||||
const sy = c.height / canvasHeight.value;
|
||||
const z = Math.min(sx, sy) * 0.95;
|
||||
pan.value = {
|
||||
x: (c.width - imageNaturalWidth.value * z) / 2,
|
||||
y: (c.height - imageNaturalHeight.value * z) / 2,
|
||||
x: (c.width - canvasWidth.value * z) / 2,
|
||||
y: (c.height - canvasHeight.value * z) / 2,
|
||||
};
|
||||
zoom.value = z;
|
||||
}
|
||||
|
|
@ -178,10 +294,9 @@ function onClick(event) {
|
|||
const px = (event.clientX - rect.left - pan.value.x) / zoom.value;
|
||||
const py = (event.clientY - rect.top - pan.value.y) / zoom.value;
|
||||
const item = pickItem(px, py);
|
||||
// Shift+click hides the picked item (overlay rect AND painted sprite still
|
||||
// shows since the .png is baked, but the SVG hitbox vanishes so you can
|
||||
// click through to whatever is underneath; combine with the hidden list
|
||||
// panel to restore visibility).
|
||||
// Shift+click hides the picked item from the live canvas redraw, so the
|
||||
// sprite vanishes instead of being masked over and you can see anything
|
||||
// underneath. Clear via the "Show hidden" / "restore all" buttons.
|
||||
if (event.shiftKey && item) {
|
||||
hideItem(item);
|
||||
return;
|
||||
|
|
@ -251,17 +366,28 @@ const overlayBoxes = computed(() => {
|
|||
return visibleItems().map((it) => ({ item: it, rect: itemPixelRect(it) }));
|
||||
});
|
||||
|
||||
// Black-out rects for shift+click-hidden items: the rendered .png is baked,
|
||||
// so we paint an opaque rectangle over each hidden sprite using the same
|
||||
// background colour the renderer used (DEFAULT_BACKGROUND in render.js =
|
||||
// rgb(18,18,18)). This lets the user see whatever real sprite lives behind
|
||||
// the obscuring one without re-running the exporter.
|
||||
const hiddenMasks = computed(() => {
|
||||
if (!scene.value || hiddenIds.value.size === 0) return [];
|
||||
return scene.value.json.items
|
||||
.filter((it) => hiddenIds.value.has(it.recordIndex))
|
||||
.map((it) => ({ item: it, rect: itemPixelRect(it) }));
|
||||
});
|
||||
// The scene canvas is inside the `v-else-if="scene"` template branch, which
|
||||
// does not mount until `loading` flips back to false. Painting during
|
||||
// `loadScene()` therefore races the DOM and leaves the canvas blank while the
|
||||
// SVG hitbox overlay still appears. Paint only after the scene branch exists.
|
||||
watch(
|
||||
[scene, loading, canvasWidth, canvasHeight],
|
||||
async ([nextScene, isLoading, width, height]) => {
|
||||
if (!nextScene || isLoading || !width || !height) return;
|
||||
await nextTick();
|
||||
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) {
|
||||
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="loading" class="muted center">Loading…</div>
|
||||
<div v-else-if="scene" class="stage" :style="transformStyle">
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src="scene.pngUrl"
|
||||
@load="onImageLoad"
|
||||
draggable="false"
|
||||
class="map-img"
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="map-canvas"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
/>
|
||||
<svg
|
||||
v-if="imageNaturalWidth"
|
||||
v-if="canvasWidth"
|
||||
class="overlay"
|
||||
:width="imageNaturalWidth"
|
||||
:height="imageNaturalHeight"
|
||||
:viewBox="`0 0 ${imageNaturalWidth} ${imageNaturalHeight}`"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
: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
|
||||
v-for="entry in overlayBoxes"
|
||||
:key="entry.item.recordIndex"
|
||||
|
|
@ -407,7 +519,7 @@ onMounted(loadIndex);
|
|||
}
|
||||
.canvas:active { cursor: grabbing; }
|
||||
.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; }
|
||||
|
||||
.sidebar {
|
||||
|
|
|
|||
0
temp_head.json
Normal file
0
temp_head.json
Normal file
Loading…
Add table
Add a link
Reference in a new issue