map viewer
This commit is contained in:
parent
93bc6e7a07
commit
2b1f1a0191
15 changed files with 2355 additions and 40 deletions
12
psx-map-exporter/viewer/index.html
Normal file
12
psx-map-exporter/viewer/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PSX Map Debug Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1207
psx-map-exporter/viewer/package-lock.json
generated
Normal file
1207
psx-map-exporter/viewer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
psx-map-exporter/viewer/package.json
Normal file
18
psx-map-exporter/viewer/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "psx-map-debug-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
424
psx-map-exporter/viewer/src/App.vue
Normal file
424
psx-map-exporter/viewer/src/App.vue
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
<script setup>
|
||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import ItemPanel from './ItemPanel.vue';
|
||||
|
||||
const PADDING = 16;
|
||||
|
||||
const indexData = ref(null);
|
||||
const indexError = ref(null);
|
||||
const selectedMap = ref(null);
|
||||
const selectedVariant = ref(null);
|
||||
const scene = shallowRef(null);
|
||||
const sceneError = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const imageRef = ref(null);
|
||||
const containerRef = ref(null);
|
||||
const imageNaturalWidth = ref(0);
|
||||
const imageNaturalHeight = ref(0);
|
||||
|
||||
const zoom = ref(1);
|
||||
const pan = ref({ x: 0, y: 0 });
|
||||
const dragging = ref(null);
|
||||
|
||||
const showHitboxes = ref(true);
|
||||
const showOnlyLayer = ref('all');
|
||||
const filterTypeHex = ref('');
|
||||
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".
|
||||
const hiddenIds = ref(new Set());
|
||||
const hiddenList = computed(() => {
|
||||
if (!scene.value || hiddenIds.value.size === 0) return [];
|
||||
return scene.value.json.items.filter((it) => hiddenIds.value.has(it.recordIndex));
|
||||
});
|
||||
|
||||
function hideItem(item) {
|
||||
if (!item || !Number.isInteger(item.recordIndex)) return;
|
||||
const next = new Set(hiddenIds.value);
|
||||
next.add(item.recordIndex);
|
||||
hiddenIds.value = next;
|
||||
if (selected.value?.recordIndex === item.recordIndex) selected.value = null;
|
||||
}
|
||||
function unhideItem(item) {
|
||||
if (!item || !Number.isInteger(item.recordIndex)) return;
|
||||
const next = new Set(hiddenIds.value);
|
||||
next.delete(item.recordIndex);
|
||||
hiddenIds.value = next;
|
||||
}
|
||||
function clearHidden() { hiddenIds.value = new Set(); }
|
||||
|
||||
async function loadIndex() {
|
||||
try {
|
||||
const res = await fetch('/api/index');
|
||||
if (!res.ok) throw new Error('Index HTTP ' + res.status);
|
||||
indexData.value = await res.json();
|
||||
} catch (error) {
|
||||
indexError.value = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const mapList = computed(() => {
|
||||
if (!indexData.value) return [];
|
||||
const seen = new Set();
|
||||
const list = [];
|
||||
for (const entry of indexData.value.maps) {
|
||||
if (entry.error) continue;
|
||||
if (seen.has(entry.map)) continue;
|
||||
seen.add(entry.map);
|
||||
list.push(entry.map);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const variantList = computed(() => indexData.value?.variants ?? []);
|
||||
|
||||
watch(mapList, (list) => {
|
||||
if (!selectedMap.value && list.length) selectedMap.value = list[0];
|
||||
});
|
||||
watch(variantList, (list) => {
|
||||
if (!selectedVariant.value && list.length) selectedVariant.value = list[0];
|
||||
});
|
||||
|
||||
async function loadScene() {
|
||||
if (!selectedMap.value || !selectedVariant.value) return;
|
||||
loading.value = true;
|
||||
sceneError.value = null;
|
||||
scene.value = null;
|
||||
selected.value = null;
|
||||
hovered.value = null;
|
||||
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`,
|
||||
};
|
||||
} catch (error) {
|
||||
sceneError.value = error.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([selectedMap, selectedVariant], loadScene);
|
||||
|
||||
function onImageLoad() {
|
||||
if (!imageRef.value) return;
|
||||
imageNaturalWidth.value = imageRef.value.naturalWidth;
|
||||
imageNaturalHeight.value = imageRef.value.naturalHeight;
|
||||
fitToContainer();
|
||||
}
|
||||
|
||||
function fitToContainer() {
|
||||
if (!containerRef.value || !imageNaturalWidth.value) return;
|
||||
const c = containerRef.value.getBoundingClientRect();
|
||||
const sx = c.width / imageNaturalWidth.value;
|
||||
const sy = c.height / imageNaturalHeight.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,
|
||||
};
|
||||
zoom.value = z;
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (!containerRef.value) return;
|
||||
event.preventDefault();
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
const newZoom = Math.max(0.05, Math.min(16, zoom.value * factor));
|
||||
pan.value = {
|
||||
x: mx - (mx - pan.value.x) * (newZoom / zoom.value),
|
||||
y: my - (my - pan.value.y) * (newZoom / zoom.value),
|
||||
};
|
||||
zoom.value = newZoom;
|
||||
}
|
||||
|
||||
function onMouseDown(event) {
|
||||
if (event.button !== 0 && event.button !== 1) return;
|
||||
dragging.value = { x: event.clientX, y: event.clientY, startPan: { ...pan.value }, moved: false };
|
||||
}
|
||||
function onMouseMove(event) {
|
||||
if (dragging.value) {
|
||||
const dx = event.clientX - dragging.value.x;
|
||||
const dy = event.clientY - dragging.value.y;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 3) dragging.value.moved = true;
|
||||
pan.value = {
|
||||
x: dragging.value.startPan.x + dx,
|
||||
y: dragging.value.startPan.y + dy,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!scene.value || !containerRef.value) return;
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
const px = (event.clientX - rect.left - pan.value.x) / zoom.value;
|
||||
const py = (event.clientY - rect.top - pan.value.y) / zoom.value;
|
||||
hovered.value = pickItem(px, py);
|
||||
}
|
||||
function onMouseUp() { dragging.value = null; }
|
||||
function onMouseLeave() { dragging.value = null; hovered.value = null; }
|
||||
|
||||
function onClick(event) {
|
||||
if (dragging.value?.moved) return;
|
||||
if (!scene.value || !containerRef.value) return;
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
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).
|
||||
if (event.shiftKey && item) {
|
||||
hideItem(item);
|
||||
return;
|
||||
}
|
||||
selected.value = item;
|
||||
}
|
||||
|
||||
function itemPixelRect(item) {
|
||||
const b = scene.value.json.bounds;
|
||||
return {
|
||||
x: (item.drawX - b.minX) + PADDING,
|
||||
y: (item.drawY - b.minY) + PADDING,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
};
|
||||
}
|
||||
|
||||
function visibleItems() {
|
||||
if (!scene.value) return [];
|
||||
let items = scene.value.json.items;
|
||||
if (hiddenIds.value.size) {
|
||||
items = items.filter((it) => !hiddenIds.value.has(it.recordIndex));
|
||||
}
|
||||
if (showOnlyLayer.value !== 'all') {
|
||||
items = items.filter((it) => (it.authoredLayer ?? 'unknown') === showOnlyLayer.value);
|
||||
}
|
||||
if (filterTypeHex.value.trim()) {
|
||||
const t = parseInt(filterTypeHex.value, 16);
|
||||
if (Number.isFinite(t)) items = items.filter((it) => it.typeWord === t);
|
||||
}
|
||||
if (filterBundleHex.value.trim()) {
|
||||
const b = parseInt(filterBundleHex.value, 16);
|
||||
if (Number.isFinite(b)) items = items.filter((it) => it.bundleAbsoluteOffset === b);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pickItem(px, py) {
|
||||
if (!scene.value) return null;
|
||||
const items = visibleItems();
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const it = items[i];
|
||||
const r = itemPixelRect(it);
|
||||
if (px >= r.x && py >= r.y && px <= r.x + r.w && py <= r.y + r.h) {
|
||||
return it;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const layerOptions = computed(() => {
|
||||
if (!scene.value) return ['all'];
|
||||
const set = new Set(['all']);
|
||||
for (const it of scene.value.json.items) set.add(it.authoredLayer ?? 'unknown');
|
||||
return [...set];
|
||||
});
|
||||
|
||||
const visibleCount = computed(() => visibleItems().length);
|
||||
|
||||
const transformStyle = computed(() => ({
|
||||
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
|
||||
transformOrigin: '0 0',
|
||||
}));
|
||||
|
||||
const overlayBoxes = computed(() => {
|
||||
if (!scene.value || !showHitboxes.value) return [];
|
||||
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) }));
|
||||
});
|
||||
|
||||
function strokeFor(item) {
|
||||
if (item === selected.value) return '#4ea1ff';
|
||||
if (item === hovered.value) return '#ffb24a';
|
||||
if (item.placeholder) return 'rgba(220, 80, 220, 0.6)';
|
||||
return 'rgba(255,255,255,0.13)';
|
||||
}
|
||||
function strokeWidthFor(item) {
|
||||
return (item === selected.value || item === hovered.value) ? 2 : 1;
|
||||
}
|
||||
function reset11() { zoom.value = 1; pan.value = { x: 0, y: 0 }; }
|
||||
|
||||
onMounted(loadIndex);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="title">PSX Map Debug Viewer</div>
|
||||
<label>Map
|
||||
<select v-model="selectedMap">
|
||||
<option v-for="m in mapList" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Variant
|
||||
<select v-model="selectedVariant">
|
||||
<option v-for="v in variantList" :key="v" :value="v">{{ v }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Layer
|
||||
<select v-model="showOnlyLayer">
|
||||
<option v-for="l in layerOptions" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>typeWord <input v-model="filterTypeHex" placeholder="hex" size="6" /></label>
|
||||
<label>bundle <input v-model="filterBundleHex" placeholder="hex" size="8" /></label>
|
||||
<label class="check"><input type="checkbox" v-model="showHitboxes" /> hitboxes</label>
|
||||
<button @click="fitToContainer">Fit</button>
|
||||
<button @click="reset11">1:1</button>
|
||||
<button v-if="hiddenIds.size" @click="clearHidden" title="Restore all shift+click-hidden items">Show hidden ({{ hiddenIds.size }})</button>
|
||||
<span class="muted">visible: {{ visibleCount }} · zoom {{ zoom.toFixed(2) }}× · shift+click to hide</span>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section
|
||||
class="canvas"
|
||||
ref="containerRef"
|
||||
@wheel="onWheel"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseLeave"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="indexError" class="error">Index error: {{ indexError }}</div>
|
||||
<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"
|
||||
/>
|
||||
<svg
|
||||
v-if="imageNaturalWidth"
|
||||
class="overlay"
|
||||
:width="imageNaturalWidth"
|
||||
:height="imageNaturalHeight"
|
||||
:viewBox="`0 0 ${imageNaturalWidth} ${imageNaturalHeight}`"
|
||||
>
|
||||
<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"
|
||||
:x="entry.rect.x"
|
||||
:y="entry.rect.y"
|
||||
:width="entry.rect.w"
|
||||
:height="entry.rect.h"
|
||||
fill="none"
|
||||
:stroke="strokeFor(entry.item)"
|
||||
:stroke-width="strokeWidthFor(entry.item)"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="sidebar">
|
||||
<h3>Hover</h3>
|
||||
<ItemPanel :item="hovered" :map-stem="selectedMap" />
|
||||
<h3>Selected
|
||||
<button v-if="selected" class="mini" @click="hideItem(selected)">hide</button>
|
||||
</h3>
|
||||
<ItemPanel :item="selected" :map-stem="selectedMap" :show-copy="true" />
|
||||
<template v-if="hiddenList.length">
|
||||
<h3>Hidden ({{ hiddenList.length }})
|
||||
<button class="mini" @click="clearHidden">restore all</button>
|
||||
</h3>
|
||||
<ul class="hidden-list">
|
||||
<li v-for="it in hiddenList" :key="it.recordIndex">
|
||||
#{{ it.recordIndex }} t0x{{ it.typeWord.toString(16) }} {{ it.authoredLayer }}
|
||||
<button class="mini" @click="unhideItem(it)">show</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app { display: flex; flex-direction: column; height: 100vh; }
|
||||
.topbar {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar label { display: flex; gap: 4px; align-items: center; font-size: 13px; color: var(--muted); }
|
||||
.topbar label.check { color: var(--text); }
|
||||
.topbar input { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; }
|
||||
.title { font-weight: bold; margin-right: 12px; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.layout { display: flex; flex: 1; min-height: 0; }
|
||||
.canvas {
|
||||
flex: 1; position: relative; overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
cursor: grab;
|
||||
}
|
||||
.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; }
|
||||
.overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
|
||||
|
||||
.sidebar {
|
||||
width: 380px; background: var(--panel); border-left: 1px solid var(--border);
|
||||
overflow: auto; padding: 12px;
|
||||
}
|
||||
.sidebar h3 { margin: 8px 0 4px; font-size: 12px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
||||
.sidebar .mini { font-size: 10px; padding: 1px 6px; text-transform: none; }
|
||||
.hidden-list { list-style: none; padding: 0; margin: 0 0 8px; font-size: 11px; }
|
||||
.hidden-list li { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.error { color: #ff6b6b; padding: 16px; }
|
||||
.center { padding: 16px; }
|
||||
</style>
|
||||
102
psx-map-exporter/viewer/src/ItemPanel.vue
Normal file
102
psx-map-exporter/viewer/src/ItemPanel.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, default: null },
|
||||
mapStem: { type: String, default: null },
|
||||
showCopy: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
function fmtHex(value, pad = 0) {
|
||||
if (value === null || value === undefined) return '—';
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return String(value);
|
||||
return '0x' + n.toString(16).padStart(pad, '0');
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
if (!props.item) return;
|
||||
navigator.clipboard?.writeText(JSON.stringify(props.item, null, 2));
|
||||
}
|
||||
|
||||
const rawWordsHex = computed(() => {
|
||||
if (!props.item?.rawWords) return null;
|
||||
return props.item.rawWords.map((w) => w.toString(16).padStart(4, '0')).join(' ');
|
||||
});
|
||||
|
||||
// Sprite preview URL: matches the layout written by writeBundleCache and
|
||||
// served by vite.config.js /sprites middleware:
|
||||
// /sprites/<mapStem>/bundle_<absoluteOffset:8x>/frame_<frameIndex:3d>.png
|
||||
const spriteUrl = computed(() => {
|
||||
if (!props.item || !props.mapStem) return null;
|
||||
const off = props.item.bundleAbsoluteOffset;
|
||||
const frame = props.item.frameIndex;
|
||||
if (!Number.isFinite(off) || !Number.isInteger(frame)) return null;
|
||||
const offHex = off.toString(16).padStart(8, '0');
|
||||
const frameStr = String(frame).padStart(3, '0');
|
||||
return `/sprites/${props.mapStem}/bundle_${offHex}/frame_${frameStr}.png`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!item" class="muted">— nothing —</div>
|
||||
<template v-else>
|
||||
<div v-if="spriteUrl" class="preview">
|
||||
<img :src="spriteUrl" :alt="`bundle ${fmtHex(item.bundleAbsoluteOffset, 8)} frame ${item.frameIndex}`" />
|
||||
<div class="preview-caption">
|
||||
bundle {{ fmtHex(item.bundleAbsoluteOffset, 8) }} · frame {{ item.frameIndex }}
|
||||
· {{ item.width }}×{{ item.height }} (anchor {{ item.originX }},{{ item.originY }})
|
||||
</div>
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tbody>
|
||||
<tr><th>recordIndex</th><td>{{ item.recordIndex }}</td></tr>
|
||||
<tr><th>instanceId</th><td>{{ item.instanceId }}</td></tr>
|
||||
<tr><th>typeWord</th><td>{{ fmtHex(item.typeWord, 2) }} ({{ item.typeWord }})</td></tr>
|
||||
<tr><th>laneWord</th><td>{{ fmtHex(item.laneWord, 4) }}</td></tr>
|
||||
<tr><th>bundle abs offset</th><td>{{ fmtHex(item.bundleAbsoluteOffset, 8) }}</td></tr>
|
||||
<tr><th>bundleSlot</th><td>{{ item.bundleSlot }}</td></tr>
|
||||
<tr><th>bundleSource</th><td>{{ item.bundleSource }}</td></tr>
|
||||
<tr><th>frameIndex</th><td>{{ item.frameIndex }} / req {{ item.requestedFrameIndex }}</td></tr>
|
||||
<tr><th>palette</th><td>def {{ item.defaultPaletteIndex }} → res {{ item.resolvedPaletteIndex }} <span class="muted">({{ item.paletteFormula }})</span></td></tr>
|
||||
<tr><th>mappingSource</th><td>{{ item.mappingSource }}</td></tr>
|
||||
<tr><th>authoredLayer</th><td>{{ item.authoredLayer }}</td></tr>
|
||||
<tr><th>sourceFamily</th><td>{{ item.sourceFamily }}</td></tr>
|
||||
<tr><th>screen X,Y</th><td>{{ item.screenX }}, {{ item.screenY }}</td></tr>
|
||||
<tr><th>world X,Y,Z</th><td>{{ item.worldX }}, {{ item.worldY }}, {{ item.worldZ }}</td></tr>
|
||||
<tr><th>draw X,Y</th><td>{{ item.drawX }}, {{ item.drawY }}</td></tr>
|
||||
<tr><th>size</th><td>{{ item.width }} × {{ item.height }} (origin {{ item.originX }},{{ item.originY }})</td></tr>
|
||||
<tr><th>flipped</th><td>{{ item.flipped }}</td></tr>
|
||||
<tr><th>stage</th><td>{{ item.stage }}</td></tr>
|
||||
<tr v-if="rawWordsHex"><th>rawWords</th><td><code>{{ rawWordsHex }}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button v-if="showCopy" @click="copyJson">Copy JSON</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kv { width: 100%; border-collapse: collapse; }
|
||||
.kv th { text-align: left; color: var(--muted); font-weight: normal; padding: 2px 6px; vertical-align: top; width: 130px; font-size: 12px; }
|
||||
.kv td { padding: 2px 6px; word-break: break-all; font-size: 12px; }
|
||||
.kv code { font-size: 11px; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
button { margin-top: 6px; }
|
||||
.preview {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.preview img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
image-rendering: pixelated;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255,255,255,0.06) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.06) 75%) 0 0 / 16px 16px,
|
||||
linear-gradient(45deg, rgba(255,255,255,0.06) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.06) 75%) 8px 8px / 16px 16px;
|
||||
}
|
||||
.preview-caption { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
</style>
|
||||
5
psx-map-exporter/viewer/src/main.js
Normal file
5
psx-map-exporter/viewer/src/main.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
38
psx-map-exporter/viewer/src/style.css
Normal file
38
psx-map-exporter/viewer/src/style.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
--bg: #121212;
|
||||
--panel: #1c1c1f;
|
||||
--panel-2: #25252a;
|
||||
--border: #2f2f36;
|
||||
--text: #e6e6e6;
|
||||
--muted: #9aa0a6;
|
||||
--accent: #4ea1ff;
|
||||
--warn: #ffb24a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button, select {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover, select:hover { border-color: var(--accent); }
|
||||
|
||||
input[type="checkbox"] { accent-color: var(--accent); }
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
110
psx-map-exporter/viewer/vite.config.js
Normal file
110
psx-map-exporter/viewer/vite.config.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const RENDER_ROOT = path.resolve(__dirname, '..', '.output-render');
|
||||
const CACHE_ROOT = path.resolve(__dirname, '..', '.cache');
|
||||
|
||||
// Tiny dev plugin that exposes the .output-render directory at /render and
|
||||
// serves a /api/index endpoint enumerating maps and variants. Keeps the app
|
||||
// dependency-free of any external server.
|
||||
function renderRootPlugin() {
|
||||
return {
|
||||
name: 'psx-render-root',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/render', async (req, res, next) => {
|
||||
try {
|
||||
const url = decodeURIComponent(req.url.split('?')[0]);
|
||||
const filePath = path.join(RENDER_ROOT, url);
|
||||
if (!filePath.startsWith(RENDER_ROOT)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const entries = await fs.readdir(filePath, { withFileTypes: true });
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }))));
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = ext === '.png' ? 'image/png'
|
||||
: ext === '.json' ? 'application/json'
|
||||
: ext === '.log' ? 'text/plain'
|
||||
: 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
const data = await fs.readFile(filePath);
|
||||
res.end(data);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
server.middlewares.use('/api/index', async (req, res) => {
|
||||
try {
|
||||
const indexPath = path.join(RENDER_ROOT, 'index.json');
|
||||
const data = await fs.readFile(indexPath, 'utf8');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.end(data);
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// Serve per-map sprite cache: /sprites/<map>/bundle_<hex>/frame_NNN.png
|
||||
// The exporter writes individual decoded sprite PNGs into
|
||||
// <projectRoot>/.cache/<mapStem>/sprites/, sharing the cache between
|
||||
// both the auto and region01 variants of the same map (bundle decoding
|
||||
// is invariant to the record-set choice).
|
||||
server.middlewares.use('/sprites', async (req, res, next) => {
|
||||
try {
|
||||
const url = decodeURIComponent(req.url.split('?')[0]);
|
||||
// url like "/L2/bundle_00085c40/frame_000.png"
|
||||
// map to "<CACHE_ROOT>/L2/sprites/bundle_00085c40/frame_000.png"
|
||||
const segments = url.split('/').filter(Boolean);
|
||||
if (segments.length < 2) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const [mapStem, ...rest] = segments;
|
||||
const filePath = path.join(CACHE_ROOT, mapStem, 'sprites', ...rest);
|
||||
if (!filePath.startsWith(CACHE_ROOT)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
const data = await fs.readFile(filePath);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.end(data);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), renderRootPlugin()],
|
||||
server: {
|
||||
port: 5180,
|
||||
strictPort: false,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue