card harness dynamic changes
This commit is contained in:
@@ -12,9 +12,11 @@ import {
|
||||
type SubstrateName,
|
||||
} from './cardMaterial'
|
||||
import { createCardGeometry } from './cardGeometry'
|
||||
import { PackOpening, type PackFixture } from './packOpening'
|
||||
import { PackOpening, packSize, rollPackContents } from './packOpening'
|
||||
import { defaultPackStyle, packStyles } from './packDesigns'
|
||||
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog'
|
||||
|
||||
type FixtureName = PackFixture
|
||||
type FixtureName = string
|
||||
type AppMode = 'Inspect' | 'Lab' | 'Pack'
|
||||
type ManipulationTarget = 'Card' | 'Camera'
|
||||
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
|
||||
@@ -50,16 +52,10 @@ interface ExperimentSnapshot {
|
||||
orbitTarget: [number, number, number]
|
||||
}
|
||||
|
||||
const fixtureAssets: Record<FixtureName, { artwork: string; mask: string }> = {
|
||||
David: {
|
||||
artwork: '/reference/david-front.png',
|
||||
mask: '/reference/david-finish-mask.png',
|
||||
},
|
||||
Timothy: {
|
||||
artwork: '/reference/timothy-front.png',
|
||||
mask: '/reference/timothy-finish-mask.png',
|
||||
},
|
||||
}
|
||||
let cardCatalog = await fetchCardCatalog()
|
||||
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs')
|
||||
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card]))
|
||||
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name
|
||||
|
||||
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
|
||||
texture.colorSpace = colorSpace
|
||||
@@ -93,12 +89,16 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||
<option>Pack</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Card
|
||||
<select id="fixture">
|
||||
<option>David</option>
|
||||
<option>Timothy</option>
|
||||
<label class="pack-style">Pack style
|
||||
<select id="pack-style" title="Change the wrapper only; use Restart to compare sealed packs" disabled>
|
||||
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label>Card
|
||||
<span class="catalog-state" id="catalog-state" aria-live="polite"></span>
|
||||
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false">
|
||||
<datalist id="fixture-options"></datalist>
|
||||
</label>
|
||||
<label>Finish
|
||||
<select id="finish">
|
||||
<option>Holographic</option>
|
||||
@@ -110,7 +110,6 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||
<select id="substrate">
|
||||
<option>Paper</option>
|
||||
<option>Linen</option>
|
||||
<option>Plastic</option>
|
||||
<option>Metal</option>
|
||||
<option>Wood</option>
|
||||
</select>
|
||||
@@ -159,7 +158,7 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||
<span id="pack-status" role="status" aria-live="polite"></span>
|
||||
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
|
||||
<div class="pack-actions">
|
||||
<button id="pack-restart" type="button">Restart</button>
|
||||
<button id="pack-restart" type="button">New pack</button>
|
||||
<button id="pack-primary" type="button">Open pack</button>
|
||||
<button id="pack-skip" type="button">Skip</button>
|
||||
</div>
|
||||
@@ -184,7 +183,9 @@ const hintElement = document.querySelector<HTMLDivElement>('#hint')!
|
||||
const topbar = document.querySelector<HTMLElement>('.topbar')!
|
||||
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
|
||||
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
|
||||
const fixtureSelect = document.querySelector<HTMLSelectElement>('#fixture')!
|
||||
const fixtureSelect = document.querySelector<HTMLInputElement>('#fixture')!
|
||||
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')!
|
||||
const catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
|
||||
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
|
||||
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
|
||||
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
|
||||
@@ -202,6 +203,23 @@ const packTearProgress = document.querySelector<HTMLProgressElement>('#pack-tear
|
||||
const packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
|
||||
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
|
||||
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
|
||||
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
|
||||
|
||||
function renderCardCatalog(catalog: CardCatalog) {
|
||||
fixtureOptions.replaceChildren(...catalog.cards.map(card => {
|
||||
const option = document.createElement('option')
|
||||
option.value = card.name
|
||||
return option
|
||||
}))
|
||||
catalogStateElement.textContent = catalog.errors.length
|
||||
? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
|
||||
: `${catalog.cards.length} cards`
|
||||
catalogStateElement.classList.toggle('has-errors', catalog.errors.length > 0)
|
||||
catalogStateElement.title = catalog.errors.join('\n')
|
||||
}
|
||||
|
||||
renderCardCatalog(cardCatalog)
|
||||
fixtureSelect.value = defaultFixture
|
||||
|
||||
function setControlsCollapsed(collapsed: boolean) {
|
||||
topbar.classList.toggle('controls-collapsed', collapsed)
|
||||
@@ -270,8 +288,9 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
|
||||
backTexture.colorSpace = THREE.SRGBColorSpace
|
||||
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
|
||||
|
||||
const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork)
|
||||
const initialMask = await loader.loadAsync(fixtureAssets.David.mask)
|
||||
const initialAsset = fixtureAssets.get(defaultFixture)!
|
||||
const initialArtwork = await loader.loadAsync(initialAsset.artwork)
|
||||
const initialMask = await loader.loadAsync(initialAsset.mask)
|
||||
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
|
||||
configureFrontTexture(initialMask, THREE.NoColorSpace)
|
||||
|
||||
@@ -413,7 +432,7 @@ surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).na
|
||||
updateMaterial()
|
||||
updateStatus()
|
||||
})
|
||||
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
|
||||
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
|
||||
pauseScriptedMotion()
|
||||
substrateSelect.value = value
|
||||
updateMaterial()
|
||||
@@ -515,7 +534,7 @@ const assetActions = {
|
||||
loadMask: () => maskFileInput.click(),
|
||||
restoreFixture: () => {
|
||||
pauseScriptedMotion()
|
||||
void loadFixture(fixtureSelect.value as FixtureName)
|
||||
void loadFixture(fixtureSelect.value)
|
||||
},
|
||||
}
|
||||
const assetFolder = gui.addFolder('Assets')
|
||||
@@ -546,7 +565,7 @@ gui.hide()
|
||||
|
||||
let mode: AppMode = 'Inspect'
|
||||
let textureRequest = 0
|
||||
let activeFixture: FixtureName = 'David'
|
||||
let activeFixture: FixtureName = defaultFixture
|
||||
let customArtworkName: string | undefined
|
||||
let customMaskName: string | undefined
|
||||
let dragging = false
|
||||
@@ -567,6 +586,8 @@ let pack: PackOpening | undefined
|
||||
let packLoading = false
|
||||
let packUIDirty = false
|
||||
let packError: string | undefined
|
||||
let packTextures: THREE.Texture[] = []
|
||||
let selectedPackStyle = defaultPackStyle
|
||||
|
||||
function updateMaterial() {
|
||||
applyMaterialControls(frontMaterial, controls)
|
||||
@@ -696,12 +717,16 @@ function updatePackUI() {
|
||||
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
||||
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
||||
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
|
||||
setIfChanged(packRestartButton, 'disabled', !pack)
|
||||
setIfChanged(packRestartButton, 'disabled', packLoading || fixtureAssets.size === 0)
|
||||
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
|
||||
setIfChanged(packRestartButton, 'textContent', 'New pack')
|
||||
setIfChanged(packStyleSelect, 'disabled', !pack || packLoading)
|
||||
setIfChanged(packStyleSelect, 'value', pack?.style ?? defaultPackStyle)
|
||||
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
|
||||
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
||||
setIfChanged(packStatusElement, 'textContent', status)
|
||||
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
|
||||
setIfChanged(statusElement, 'textContent',
|
||||
`Random foil pack · ${packSize} cards · equal card / material / finish odds`)
|
||||
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
|
||||
}
|
||||
|
||||
@@ -709,8 +734,8 @@ function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
|
||||
if (target[key] !== value) target[key] = value
|
||||
}
|
||||
|
||||
async function preparePack() {
|
||||
if (pack) {
|
||||
async function preparePack(newRoll = false) {
|
||||
if (pack && !newRoll) {
|
||||
pack.syncLighting(frontMaterial)
|
||||
pack.setActive(true)
|
||||
updatePackUI()
|
||||
@@ -719,13 +744,26 @@ async function preparePack() {
|
||||
if (packLoading) return
|
||||
packLoading = true
|
||||
packError = undefined
|
||||
if (newRoll && pack) {
|
||||
selectedPackStyle = pack.style
|
||||
pack.dispose()
|
||||
pack = undefined
|
||||
for (const texture of packTextures) texture.dispose()
|
||||
packTextures = []
|
||||
}
|
||||
updatePackUI()
|
||||
let preparedPack: PackOpening | undefined
|
||||
let textures: THREE.Texture[] = []
|
||||
try {
|
||||
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
|
||||
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
|
||||
fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask]
|
||||
const contents = rollPackContents([...fixtureAssets.keys()])
|
||||
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
|
||||
const selectedAssets = selectedNames.map(name => {
|
||||
const asset = fixtureAssets.get(name)
|
||||
if (!asset) throw new Error(`Card "${name}" disappeared from the catalog while rolling`)
|
||||
return asset
|
||||
})
|
||||
// Only cards selected for this pack are loaded; a large catalog does not consume GPU memory.
|
||||
const paths = selectedAssets.flatMap(asset => [asset.artwork, asset.mask])
|
||||
const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
|
||||
const failure = results.find((result) => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') {
|
||||
@@ -739,18 +777,21 @@ async function preparePack() {
|
||||
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
|
||||
return result.value
|
||||
})
|
||||
const selectedTextures = new Map(selectedAssets.map((asset, index) => [
|
||||
asset.name,
|
||||
{ artwork: textures[index * 2], mask: textures[index * 2 + 1] },
|
||||
]))
|
||||
preparedPack = new PackOpening({
|
||||
canvas,
|
||||
textures: {
|
||||
David: { artwork: textures[0], mask: textures[1] },
|
||||
Timothy: { artwork: textures[2], mask: textures[3] },
|
||||
},
|
||||
contents,
|
||||
textures: selectedTextures,
|
||||
backMaterial,
|
||||
normalMap,
|
||||
environment: activeEnvironment,
|
||||
lightPosition,
|
||||
onChange: () => { packUIDirty = true },
|
||||
})
|
||||
preparedPack.setStyle(selectedPackStyle)
|
||||
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
|
||||
let preparedEnvironment: THREE.Texture | null
|
||||
let preparedLightType: LightType
|
||||
@@ -765,6 +806,7 @@ async function preparePack() {
|
||||
preparedPack.setActive(mode === 'Pack')
|
||||
scene.add(preparedPack.root)
|
||||
pack = preparedPack
|
||||
packTextures = textures
|
||||
} catch (error) {
|
||||
preparedPack?.dispose()
|
||||
for (const texture of textures) texture.dispose()
|
||||
@@ -789,7 +831,7 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
|
||||
function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const snapshot = value as Record<string, unknown>
|
||||
const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy'
|
||||
const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
|
||||
const finishValid =
|
||||
snapshot.finish === 'Printed ink' ||
|
||||
snapshot.finish === 'Foil' ||
|
||||
@@ -986,6 +1028,9 @@ function captureExperimentPng() {
|
||||
|
||||
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
|
||||
pauseScriptedMotion()
|
||||
if (!fixtureAssets.has(snapshot.fixture)) {
|
||||
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
|
||||
}
|
||||
fixtureSelect.value = snapshot.fixture
|
||||
await loadFixture(snapshot.fixture)
|
||||
controls.finish = snapshot.finish
|
||||
@@ -1072,7 +1117,8 @@ function applyInspectionPose(pose: InspectionPose) {
|
||||
|
||||
function resetView() {
|
||||
pauseScriptedMotion()
|
||||
const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined
|
||||
const resetFixture = activeFixture !== defaultFixture ||
|
||||
customArtworkName !== undefined || customMaskName !== undefined
|
||||
rotationTarget.set(-0.06, 0.12, 0)
|
||||
cardRoot.rotation.copy(rotationTarget)
|
||||
camera.position.set(0, 0, 8.2)
|
||||
@@ -1080,7 +1126,7 @@ function resetView() {
|
||||
orbit.update()
|
||||
modeSelect.value = 'Inspect'
|
||||
controls.target = 'Card'
|
||||
fixtureSelect.value = 'David'
|
||||
fixtureSelect.value = defaultFixture
|
||||
finishSelect.value = 'Holographic'
|
||||
substrateSelect.value = 'Paper'
|
||||
poseSelect.value = 'Front'
|
||||
@@ -1096,7 +1142,7 @@ function resetView() {
|
||||
updateEdgeMaterial()
|
||||
updateMode()
|
||||
if (resetFixture) {
|
||||
void loadFixture('David')
|
||||
void loadFixture(defaultFixture)
|
||||
} else {
|
||||
updateStatus()
|
||||
}
|
||||
@@ -1107,7 +1153,13 @@ async function loadFixture(nextFixture: FixtureName) {
|
||||
const request = ++textureRequest
|
||||
loadingElement.hidden = false
|
||||
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
|
||||
const asset = fixtureAssets[nextFixture]
|
||||
const asset = fixtureAssets.get(nextFixture)
|
||||
if (!asset) {
|
||||
loadingElement.hidden = true
|
||||
fixtureSelect.value = activeFixture
|
||||
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
@@ -1119,6 +1171,7 @@ async function loadFixture(nextFixture: FixtureName) {
|
||||
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
|
||||
throw artworkResult.reason
|
||||
}
|
||||
|
||||
if (maskResult.status === 'rejected') {
|
||||
artworkResult.value.dispose()
|
||||
throw maskResult.reason
|
||||
@@ -1157,6 +1210,45 @@ async function loadFixture(nextFixture: FixtureName) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFixtureName(value: string) {
|
||||
if (fixtureAssets.has(value)) return value
|
||||
const normalized = value.toLocaleLowerCase()
|
||||
return [...fixtureAssets.keys()].find(name => name.toLocaleLowerCase() === normalized)
|
||||
}
|
||||
|
||||
async function refreshCardCatalog() {
|
||||
try {
|
||||
const nextCatalog = await fetchCardCatalog()
|
||||
if (!nextCatalog.cards.length) {
|
||||
throw new Error(nextCatalog.errors.join('; ') || 'No valid artwork/mask pairs found')
|
||||
}
|
||||
const previousAsset: CardAsset | undefined = fixtureAssets.get(activeFixture)
|
||||
const nextAssets = new Map(nextCatalog.cards.map(card => [card.name, card]))
|
||||
const nextAsset = nextAssets.get(activeFixture)
|
||||
cardCatalog = nextCatalog
|
||||
fixtureAssets = nextAssets
|
||||
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
|
||||
renderCardCatalog(nextCatalog)
|
||||
if (nextCatalog.errors.length) {
|
||||
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
|
||||
}
|
||||
if (
|
||||
nextAsset &&
|
||||
previousAsset?.revision !== nextAsset.revision &&
|
||||
customArtworkName === undefined &&
|
||||
customMaskName === undefined
|
||||
) {
|
||||
await loadFixture(activeFixture)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
catalogStateElement.textContent = 'Catalog error'
|
||||
catalogStateElement.classList.add('has-errors')
|
||||
catalogStateElement.title = message
|
||||
statusElement.textContent = `Could not refresh card catalog: ${message}`
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
|
||||
const request = ++textureRequest
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
@@ -1331,17 +1423,46 @@ packPrimaryButton.addEventListener('click', () => {
|
||||
if (pack) pack.primary()
|
||||
else void preparePack()
|
||||
})
|
||||
packRestartButton.addEventListener('click', () => pack?.restart())
|
||||
packRestartButton.addEventListener('click', () => { void preparePack(true) })
|
||||
packSkipButton.addEventListener('click', () => pack?.skip())
|
||||
packStyleSelect.addEventListener('change', () => {
|
||||
try {
|
||||
const style = packStyles.find(style => style === packStyleSelect.value)
|
||||
if (!style || !pack) throw new Error('The selected pack style is unavailable')
|
||||
selectedPackStyle = style
|
||||
pack.setStyle(style)
|
||||
updatePackUI()
|
||||
} catch (error) {
|
||||
console.error('Could not change pack style', error)
|
||||
packStyleSelect.value = pack?.style ?? defaultPackStyle
|
||||
packStatusElement.textContent = `Pack style unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
})
|
||||
|
||||
modeSelect.addEventListener('change', () => {
|
||||
pauseScriptedMotion()
|
||||
updateMode()
|
||||
})
|
||||
fixtureSelect.addEventListener('change', () => {
|
||||
function selectFixtureFromPicker() {
|
||||
const fixture = resolveFixtureName(fixtureSelect.value.trim())
|
||||
if (!fixture) {
|
||||
statusElement.textContent = `No card named "${fixtureSelect.value.trim()}" exists in the card-art folder.`
|
||||
fixtureSelect.value = activeFixture
|
||||
return
|
||||
}
|
||||
fixtureSelect.value = fixture
|
||||
pauseScriptedMotion()
|
||||
void loadFixture(fixtureSelect.value as FixtureName)
|
||||
void loadFixture(fixture)
|
||||
}
|
||||
fixtureSelect.addEventListener('change', selectFixtureFromPicker)
|
||||
fixtureSelect.addEventListener('focus', () => { void refreshCardCatalog() })
|
||||
fixtureSelect.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') selectFixtureFromPicker()
|
||||
})
|
||||
window.addEventListener('focus', () => { void refreshCardCatalog() })
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
|
||||
}
|
||||
substrateSelect.addEventListener('change', () => {
|
||||
pauseScriptedMotion()
|
||||
controls.substrate = substrateSelect.value as SubstrateName
|
||||
|
||||
Reference in New Issue
Block a user