card harness dynamic changes
@@ -113,7 +113,7 @@ Changing a light type, color, position, intensity, environment contribution, or
|
||||
| Control | Default | Available range |
|
||||
|---|---:|---:|
|
||||
| Finish | Holographic | Printed ink / Foil / Holographic |
|
||||
| Material | Paper | Paper / Linen / Plastic / Metal / Wood |
|
||||
| Material | Paper | Paper / Linen / Metal / Wood |
|
||||
| Finish strength | 0.60 | 0-1 |
|
||||
| Roughness | 0.23 | 0.05-0.8 |
|
||||
| Surface detail | 0.14 | 0-0.45 |
|
||||
|
||||
@@ -7,7 +7,7 @@ The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFA
|
||||
## Included proof scope
|
||||
|
||||
- David printed-ink, foil, and holographic presets
|
||||
- Independently selectable paper, linen, plastic, metal, and wood substrates
|
||||
- Independently selectable paper, linen, metal, and wood substrates
|
||||
- Timothy as the second-artwork fixture
|
||||
- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy
|
||||
- Procedural rounded-card geometry generated from shared physical dimensions
|
||||
@@ -28,6 +28,21 @@ Transform gizmos and a general timeline are intentionally deferred. The separate
|
||||
## Tear-open foil pack prototype
|
||||
|
||||
Choose **Pack** in the Mode selector (expand **Controls** first on mobile).
|
||||
Use **Pack style** in the same toolbar to compare three wrapper-only design studies:
|
||||
|
||||
- **Cathedral glass:** deep blue, geometric stained-glass window artwork, and restrained gold.
|
||||
- **Illuminated manuscript:** warm ivory, burgundy, and botanical manuscript linework.
|
||||
- **Quiet modern** (initial selection): dark green, generous space, and a small sacred emblem.
|
||||
|
||||
All three use the same wording, foil material, pouch geometry, lighting, card contents,
|
||||
and opening animation. These are alternative visual studies, not different pack tiers
|
||||
or reward odds. Switching styles changes the front, back, and matching tear strip in
|
||||
place, preserving the current tear/inspection state. Use **Restart** to compare sealed
|
||||
packs; the chosen style is retained across Restart and mode switches, but not a page
|
||||
reload. On mobile, reopen **Controls** after entering Pack to access the selector.
|
||||
The six front/back textures are generated once and uploaded during pack preparation,
|
||||
then reused for instant switching. No illustration service or external asset is needed.
|
||||
|
||||
Drag the gold top seam **to the right**, or use **Tear open** for the same authored opening without dragging:
|
||||
|
||||
**Sealed → progressive tear → strip curls away → mouth spreads → staggered card extraction → face-down stack → lifting → lifted → revealing → inspecting → advancing → next stack / complete.**
|
||||
@@ -105,8 +120,8 @@ silhouette, back, or thickness. This linen look was visually approved September
|
||||
2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in
|
||||
[`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration
|
||||
is required: selecting Linen applies it in Inspect, Lab, and the authored pack.
|
||||
Plastic has a smooth clear-coat
|
||||
highlight even in Printed ink mode. Wood uses raised longitudinal grain with
|
||||
The legacy Plastic shader recipe remains readable by older saved experiments but is
|
||||
not offered in the UI or randomized packs. Wood uses raised longitudinal grain with
|
||||
bounded procedural parallax, stronger relief, and a restrained warm tint.
|
||||
The artwork stays anchored while grain shading and tint shift together.
|
||||
Metal retains its reflective underprint and uses luminance-driven recesses:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"assets": "node scripts/generate-reference-assets.mjs",
|
||||
"catalog": "node scripts/card-catalog.mjs",
|
||||
"export:blender": "node scripts/export-card-mesh.mjs",
|
||||
"test": "node --test scripts/*.test.mjs",
|
||||
"predev": "npm run assets",
|
||||
|
||||
BIN
spikes/card-harness/public/card-art/David-mask.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
spikes/card-harness/public/card-art/David.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
21
spikes/card-harness/public/card-art/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Card Art Drop Folder
|
||||
|
||||
Add each card as one PNG artwork and one matching PNG finish mask:
|
||||
|
||||
```text
|
||||
david.png
|
||||
david-mask.png
|
||||
```
|
||||
|
||||
The filename before `.png` is the card name shown in the harness. Names are
|
||||
case-sensitive. Artwork and mask must:
|
||||
|
||||
- have identical dimensions;
|
||||
- use a 5:7 aspect ratio;
|
||||
- be fully opaque;
|
||||
- use an opaque grayscale mask.
|
||||
|
||||
During `npm run dev`, adding or replacing either PNG refreshes the searchable
|
||||
card catalog automatically. Reopening or focusing the Card field also forces a
|
||||
rescan. Production builds generate `manifest.json` from this folder and fail if
|
||||
any pair is missing or invalid.
|
||||
BIN
spikes/card-harness/public/card-art/Timothy-mask.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
spikes/card-harness/public/card-art/Timothy.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
22
spikes/card-harness/public/card-art/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"cards": [
|
||||
{
|
||||
"name": "David",
|
||||
"artwork": "/card-art/David.png?v=a5541f0b4b68e13e",
|
||||
"mask": "/card-art/David-mask.png?v=a5541f0b4b68e13e",
|
||||
"revision": "a5541f0b4b68e13e",
|
||||
"width": 1060,
|
||||
"height": 1484
|
||||
},
|
||||
{
|
||||
"name": "Timothy",
|
||||
"artwork": "/card-art/Timothy.png?v=1118b083057106d4",
|
||||
"mask": "/card-art/Timothy-mask.png?v=1118b083057106d4",
|
||||
"revision": "1118b083057106d4",
|
||||
"width": 1060,
|
||||
"height": 1484
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
BIN
spikes/card-harness/public/reference/card-back-old.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
spikes/card-harness/public/reference/card-back-white.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.7 MiB |
111
spikes/card-harness/scripts/card-catalog.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { PNG } from 'pngjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
export const harnessRoot = resolve(here, '..')
|
||||
export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art')
|
||||
export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json')
|
||||
|
||||
function parsePng(name, bytes) {
|
||||
try {
|
||||
return PNG.sync.read(bytes)
|
||||
} catch (error) {
|
||||
throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePair(name, artworkBytes, maskBytes) {
|
||||
const artwork = parsePng(`${name}.png`, artworkBytes)
|
||||
const mask = parsePng(`${name}-mask.png`, maskBytes)
|
||||
if (artwork.width * 7 !== artwork.height * 5) {
|
||||
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`)
|
||||
}
|
||||
if (mask.width !== artwork.width || mask.height !== artwork.height) {
|
||||
throw new Error(
|
||||
`${name}-mask.png must match ${name}.png dimensions; found ` +
|
||||
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`,
|
||||
)
|
||||
}
|
||||
for (let offset = 0; offset < artwork.data.length; offset += 4) {
|
||||
if (artwork.data[offset + 3] !== 255) {
|
||||
throw new Error(`${name}.png must be fully opaque for the runtime card surface`)
|
||||
}
|
||||
if (
|
||||
mask.data[offset] !== mask.data[offset + 1] ||
|
||||
mask.data[offset] !== mask.data[offset + 2] ||
|
||||
mask.data[offset + 3] !== 255
|
||||
) {
|
||||
throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`)
|
||||
}
|
||||
}
|
||||
return { width: artwork.width, height: artwork.height }
|
||||
}
|
||||
|
||||
export async function scanCardCatalog(root = cardArtRoot) {
|
||||
await mkdir(root, { recursive: true })
|
||||
const files = (await readdir(root, { withFileTypes: true }))
|
||||
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
|
||||
.map(entry => entry.name)
|
||||
const fileSet = new Set(files)
|
||||
const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png'))
|
||||
const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png'))
|
||||
const cards = []
|
||||
const errors = []
|
||||
|
||||
for (const artworkFile of artworkFiles) {
|
||||
const name = artworkFile.slice(0, -4)
|
||||
const maskFile = `${name}-mask.png`
|
||||
if (!fileSet.has(maskFile)) {
|
||||
errors.push(`Missing ${maskFile} for ${artworkFile}`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const [artworkBytes, maskBytes] = await Promise.all([
|
||||
readFile(resolve(root, artworkFile)),
|
||||
readFile(resolve(root, maskFile)),
|
||||
])
|
||||
const dimensions = validatePair(name, artworkBytes, maskBytes)
|
||||
const revision = createHash('sha256')
|
||||
.update(artworkBytes)
|
||||
.update(maskBytes)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
cards.push({
|
||||
name,
|
||||
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`,
|
||||
mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`,
|
||||
revision,
|
||||
...dimensions,
|
||||
})
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
for (const maskFile of maskFiles) {
|
||||
const artworkFile = `${maskFile.slice(0, -9)}.png`
|
||||
if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`)
|
||||
}
|
||||
|
||||
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||
errors.sort()
|
||||
return { schemaVersion: 1, cards, errors }
|
||||
}
|
||||
|
||||
export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) {
|
||||
const catalog = await scanCardCatalog(root)
|
||||
await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`)
|
||||
return catalog
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const catalog = await writeCardCatalog()
|
||||
console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
|
||||
if (catalog.errors.length) {
|
||||
for (const error of catalog.errors) console.error(`- ${error}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
47
spikes/card-harness/scripts/card-catalog.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { PNG } from 'pngjs'
|
||||
import { scanCardCatalog } from './card-catalog.mjs'
|
||||
|
||||
function png(width, height, pixel) {
|
||||
const image = new PNG({ width, height })
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
image.data.set(pixel, offset)
|
||||
}
|
||||
return PNG.sync.write(image)
|
||||
}
|
||||
|
||||
test('catalog pairs filename-based card art with validated masks', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-'))
|
||||
t.after(() => rm(root, { recursive: true }))
|
||||
await Promise.all([
|
||||
writeFile(resolve(root, 'david.png'), png(10, 14, [20, 40, 60, 255])),
|
||||
writeFile(resolve(root, 'david-mask.png'), png(10, 14, [128, 128, 128, 255])),
|
||||
])
|
||||
const catalog = await scanCardCatalog(root)
|
||||
assert.deepEqual(catalog.errors, [])
|
||||
assert.equal(catalog.cards.length, 1)
|
||||
assert.equal(catalog.cards[0].name, 'david')
|
||||
assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/)
|
||||
assert.equal(catalog.cards[0].width, 10)
|
||||
assert.equal(catalog.cards[0].height, 14)
|
||||
})
|
||||
|
||||
test('catalog reports missing pairs and invalid runtime images explicitly', async (t) => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-'))
|
||||
t.after(() => rm(root, { recursive: true }))
|
||||
await Promise.all([
|
||||
writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])),
|
||||
writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])),
|
||||
writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])),
|
||||
writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])),
|
||||
])
|
||||
const catalog = await scanCardCatalog(root)
|
||||
assert.deepEqual(catalog.cards, [])
|
||||
assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png')))
|
||||
assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png')))
|
||||
assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque')))
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { installDOM, TestCanvas, geometryHash, wrapperCases } from './test-suppo
|
||||
|
||||
const { createFoilWrapper } = await import('../src/foilWrapper.ts')
|
||||
const { PackOpening } = await import('../src/packOpening.ts')
|
||||
const { packStyles, defaultPackStyle, createPackTexture } = await import('../src/packDesigns.ts')
|
||||
|
||||
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
|
||||
const originalHashes = [
|
||||
@@ -56,6 +57,49 @@ test('only changed surfaces recompute normals, bounds and upload buffers', (t) =
|
||||
assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
|
||||
})
|
||||
|
||||
test('three cached pack designs change all printed surfaces without changing geometry or materials', () => {
|
||||
installDOM()
|
||||
const wrapper = createFoilWrapper()
|
||||
assert.equal(packStyles.length, 3)
|
||||
assert.equal(wrapper.style, defaultPackStyle)
|
||||
assert.equal(wrapper.textures.length, 6)
|
||||
const materials = wrapper.root.children.map(mesh => mesh.material)
|
||||
const maps = new Set()
|
||||
for (const style of packStyles) {
|
||||
wrapper.setStyle(style)
|
||||
assert.equal(wrapper.style, style)
|
||||
const [front, back, strip] = wrapper.root.children.map(mesh => mesh.material.map)
|
||||
assert.equal(front, strip, 'ribbon must use the same printed sheet as the front')
|
||||
assert.notEqual(front, back)
|
||||
maps.add(front)
|
||||
maps.add(back)
|
||||
for (const texture of [front, back]) {
|
||||
assert.equal(texture.image.width, 1024)
|
||||
assert.equal(texture.image.height, 1400)
|
||||
assert.equal(texture.colorSpace, THREE.SRGBColorSpace)
|
||||
}
|
||||
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material), materials)
|
||||
wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => {
|
||||
wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit)
|
||||
assert.equal(geometryHash(wrapper), originalHashes[index], `${style}, pose ${index}`)
|
||||
})
|
||||
wrapper.setStyle(style)
|
||||
assert.equal(wrapper.root.children[0].material.map, front, 'selecting the same style reuses its texture')
|
||||
}
|
||||
assert.equal(maps.size, 6)
|
||||
const before = wrapper.root.children.map(mesh => mesh.material.map)
|
||||
assert.throws(() => wrapper.setStyle('unknown'), /Unknown pack style/)
|
||||
assert.deepEqual(wrapper.root.children.map(mesh => mesh.material.map), before)
|
||||
wrapper.dispose()
|
||||
})
|
||||
|
||||
test('pack textures reject invalid styles and unavailable drawing contexts', (t) => {
|
||||
installDOM()
|
||||
assert.throws(() => createPackTexture('unknown'))
|
||||
t.mock.method(TestCanvas.prototype, 'getContext', () => null)
|
||||
assert.throws(() => createPackTexture(defaultPackStyle))
|
||||
})
|
||||
|
||||
function setupPack(t, reducedMotion = false) {
|
||||
installDOM(reducedMotion)
|
||||
let now = 1000
|
||||
@@ -158,6 +202,39 @@ test('interruption and mode changes preserve progress without deforming in point
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
})
|
||||
|
||||
test('style switching preserves partial tears, card order, restart selection and retained mode state', (t) => {
|
||||
const { pack, canvas, step } = setupPack(t)
|
||||
let reveals = 0
|
||||
canvas.addEventListener('packreveal', () => { reveals++ })
|
||||
pack.primary()
|
||||
step(600)
|
||||
pack.pointerDown(pointer(1, 0, 0))
|
||||
pack.pointerUp(pointer(1, 0, 0))
|
||||
const progress = pack.tearProgress
|
||||
const geometry = geometryHash(pack.wrapper)
|
||||
const cardSpecs = pack.cards.map(card => card.material.uuid)
|
||||
for (const style of packStyles) {
|
||||
pack.setStyle(style)
|
||||
assert.equal(pack.style, style)
|
||||
assert.equal(pack.tearProgress, progress)
|
||||
assert.equal(pack.paused, true)
|
||||
assert.equal(pack.state, 'opening')
|
||||
assert.equal(geometryHash(pack.wrapper), geometry)
|
||||
assert.deepEqual(pack.cards.map(card => card.material.uuid), cardSpecs)
|
||||
assert.equal(reveals, 0)
|
||||
}
|
||||
pack.setStyle(packStyles[0])
|
||||
pack.setActive(false)
|
||||
pack.setActive(true)
|
||||
assert.equal(pack.style, packStyles[0])
|
||||
assert.equal(pack.tearProgress, progress)
|
||||
pack.restart()
|
||||
assert.equal(pack.style, packStyles[0])
|
||||
assert.equal(pack.state, 'sealed')
|
||||
assert.equal(pack.tearProgress, 0)
|
||||
pack.dispose()
|
||||
})
|
||||
|
||||
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
|
||||
const { pack, canvas, step } = setupPack(t)
|
||||
const seam = pack.seamScreenBounds()
|
||||
@@ -267,7 +344,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
|
||||
finishCompile()
|
||||
await preparation
|
||||
assert.equal(uploaded.length, new Set(uploaded).size)
|
||||
assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps')
|
||||
assert.equal(uploaded.length, 13, '4 artwork/mask textures, normal, environment, back and 6 wrapper maps')
|
||||
for (const { artwork, mask } of Object.values(pack.options.textures)) {
|
||||
assert.ok(uploaded.includes(artwork))
|
||||
assert.ok(uploaded.includes(mask))
|
||||
@@ -275,6 +352,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
|
||||
assert.ok(uploaded.includes(backTexture))
|
||||
assert.ok(uploaded.includes(pack.options.normalMap))
|
||||
assert.ok(uploaded.includes(scene.environment))
|
||||
for (const texture of pack.wrapper.textures) assert.ok(uploaded.includes(texture), 'inactive pack styles must also be GPU-ready')
|
||||
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
|
||||
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
|
||||
assert.equal(geometryHash(pack.wrapper), before)
|
||||
@@ -333,8 +411,7 @@ test('pack cleanup disposes owned resources without disposing shared or caller-o
|
||||
materials.add(object.material)
|
||||
})
|
||||
materials.delete(pack.options.backMaterial)
|
||||
const resources = [...geometries, ...materials,
|
||||
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
|
||||
const resources = [...geometries, ...materials, ...pack.wrapper.textures]
|
||||
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
|
||||
pack.dispose()
|
||||
assert.equal(pack.root.parent, null)
|
||||
|
||||
@@ -27,6 +27,10 @@ export class TestCanvas extends EventTarget {
|
||||
return {
|
||||
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
|
||||
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
|
||||
beginPath() {}, closePath() {}, moveTo() {}, lineTo() {}, bezierCurveTo() {},
|
||||
quadraticCurveTo() {}, arc() {}, ellipse() {}, rect() {}, roundRect() {},
|
||||
fill() {}, stroke() {}, clip() {}, scale() {}, setLineDash() {},
|
||||
measureText(text) { return { width: text.length * 22 } },
|
||||
}
|
||||
}
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: this.width, height: this.height } }
|
||||
|
||||
60
spikes/card-harness/src/cardCatalog.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export interface CardAsset {
|
||||
name: string
|
||||
artwork: string
|
||||
mask: string
|
||||
revision: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface CardCatalog {
|
||||
schemaVersion: 1
|
||||
cards: CardAsset[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
function isCardAsset(value: unknown): value is CardAsset {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const card = value as Record<string, unknown>
|
||||
return typeof card.name === 'string' && card.name.length > 0 &&
|
||||
typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') &&
|
||||
typeof card.mask === 'string' && card.mask.startsWith('/card-art/') &&
|
||||
typeof card.revision === 'string' &&
|
||||
Number.isInteger(card.width) && Number.isInteger(card.height)
|
||||
}
|
||||
|
||||
export async function fetchCardCatalog(): Promise<CardCatalog> {
|
||||
const urls = import.meta.env.DEV
|
||||
? ['/__card_catalog', '/card-art/manifest.json']
|
||||
: ['/card-art/manifest.json']
|
||||
const failures: string[] = []
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error(`request failed (${response.status})`)
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(`expected JSON but received ${contentType || 'an unknown content type'}`)
|
||||
}
|
||||
return validateCardCatalog(await response.json())
|
||||
} catch (error) {
|
||||
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
throw new Error(`Card catalog unavailable: ${failures.join('; ')}`)
|
||||
}
|
||||
|
||||
function validateCardCatalog(value: unknown): CardCatalog {
|
||||
if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object')
|
||||
const catalog = value as Record<string, unknown>
|
||||
if (
|
||||
catalog.schemaVersion !== 1 ||
|
||||
!Array.isArray(catalog.cards) ||
|
||||
!catalog.cards.every(isCardAsset) ||
|
||||
!Array.isArray(catalog.errors) ||
|
||||
!catalog.errors.every(error => typeof error === 'string')
|
||||
) {
|
||||
throw new Error('Card catalog has an unsupported or invalid format')
|
||||
}
|
||||
return catalog as unknown as CardCatalog
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
import { createPackTexture, defaultPackStyle, packStyles, type PackStyle } from './packDesigns'
|
||||
|
||||
const halfWidth = 1.68
|
||||
const bottom = -2.24
|
||||
@@ -7,50 +8,6 @@ const top = 2.28
|
||||
const midDepth = -0.09
|
||||
const smooth = THREE.MathUtils.smoothstep
|
||||
|
||||
function wrapperTexture(back = false) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 1024
|
||||
canvas.height = 1400
|
||||
const context = canvas.getContext('2d')!
|
||||
context.fillStyle = '#263a40'
|
||||
context.fillRect(0, 0, 1024, 1400)
|
||||
const gradient = context.createLinearGradient(0, 0, 1024, 1400)
|
||||
gradient.addColorStop(0, '#ffffff0c')
|
||||
gradient.addColorStop(0.45, '#ffffff00')
|
||||
gradient.addColorStop(1, '#00000022')
|
||||
context.fillStyle = gradient
|
||||
context.fillRect(0, 0, 1024, 1400)
|
||||
context.strokeStyle = '#a59569'
|
||||
context.lineWidth = 2
|
||||
context.strokeRect(66, 155, 892, 1100)
|
||||
context.strokeRect(80, 169, 864, 1072)
|
||||
context.fillStyle = '#baa775'
|
||||
context.fillRect(0, 0, 1024, 100)
|
||||
context.fillRect(0, 1315, 1024, 85)
|
||||
context.textAlign = 'center'
|
||||
context.fillStyle = '#283339'
|
||||
context.font = 'bold 19px sans-serif'
|
||||
context.fillText('P U L L T O O P E N →', 512, 58)
|
||||
context.fillStyle = '#d8c994'
|
||||
context.save()
|
||||
context.translate(512, 480)
|
||||
context.rotate(Math.PI / 4)
|
||||
context.strokeRect(-100, -100, 200, 200)
|
||||
context.strokeRect(-80, -80, 160, 160)
|
||||
context.restore()
|
||||
context.font = '56px Georgia'
|
||||
context.fillText('S', 512, 500)
|
||||
context.font = '44px Georgia'
|
||||
context.fillText('SANCTIFICATION', 512, 740)
|
||||
context.font = '21px sans-serif'
|
||||
context.fillText('C O L L E C T O R S E R I E S', 512, 795)
|
||||
context.font = '19px sans-serif'
|
||||
context.fillText(back ? 'AUTHORED EDITION / 001' : 'THREE CARDS / ONE COLLECTION', 512, 1175)
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
return texture
|
||||
}
|
||||
|
||||
/**
|
||||
* Two joined pouch surfaces and one sealed ribbon, never a label over a shell.
|
||||
* All deformation is evaluated from immutable UVs, not accumulated frame deltas.
|
||||
@@ -58,13 +15,37 @@ function wrapperTexture(back = false) {
|
||||
export function createFoilWrapper() {
|
||||
const root = new THREE.Group()
|
||||
root.name = 'PACK_FOIL_WRAPPER'
|
||||
const frontTexture = wrapperTexture()
|
||||
const designs = new Map<PackStyle, { front: THREE.CanvasTexture; back: THREE.CanvasTexture }>()
|
||||
const textures: THREE.Texture[] = []
|
||||
try {
|
||||
for (const style of packStyles) {
|
||||
const front = createPackTexture(style)
|
||||
textures.push(front)
|
||||
const back = createPackTexture(style, true)
|
||||
textures.push(back)
|
||||
designs.set(style, { front, back })
|
||||
}
|
||||
} catch (error) {
|
||||
for (const texture of textures) texture.dispose()
|
||||
throw error
|
||||
}
|
||||
let activeStyle = defaultPackStyle
|
||||
const initialDesign = designs.get(activeStyle)!
|
||||
const frontMaterial = new THREE.MeshStandardMaterial({
|
||||
map: frontTexture, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
|
||||
map: initialDesign.front, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
|
||||
})
|
||||
const backMaterial = frontMaterial.clone()
|
||||
backMaterial.map = wrapperTexture(true)
|
||||
backMaterial.map = initialDesign.back
|
||||
const stripMaterial = frontMaterial.clone()
|
||||
function setStyle(style: PackStyle) {
|
||||
const design = designs.get(style)
|
||||
if (!design) throw new Error(`Unknown pack style: ${style}`)
|
||||
if (style === activeStyle) return
|
||||
frontMaterial.map = design.front
|
||||
backMaterial.map = design.back
|
||||
stripMaterial.map = design.front
|
||||
activeStyle = style
|
||||
}
|
||||
const sheets = [1, -1].map((side) => {
|
||||
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
|
||||
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
|
||||
@@ -176,9 +157,8 @@ export function createFoilWrapper() {
|
||||
function dispose() {
|
||||
for (const { mesh } of sheets) mesh.geometry.dispose()
|
||||
stripGeometry.dispose()
|
||||
frontTexture.dispose()
|
||||
backMaterial.map!.dispose()
|
||||
for (const texture of textures) texture.dispose()
|
||||
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
|
||||
}
|
||||
return { root, deform, dispose }
|
||||
return { root, deform, dispose, setStyle, textures: Object.freeze(textures), get style() { return activeStyle } }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
365
spikes/card-harness/src/packDesigns.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
export const packStyles = ['Cathedral glass', 'Illuminated manuscript', 'Quiet modern'] as const
|
||||
export type PackStyle = typeof packStyles[number]
|
||||
export const defaultPackStyle: PackStyle = 'Quiet modern'
|
||||
|
||||
type Ink = CanvasRenderingContext2D
|
||||
type Point = readonly [number, number]
|
||||
|
||||
const width = 1024
|
||||
const height = 1400
|
||||
const gold = '#c7a365'
|
||||
const serif = 'Georgia, "Times New Roman", serif'
|
||||
const sans = 'Arial, Helvetica, sans-serif'
|
||||
|
||||
function line(ctx: Ink, points: readonly Point[], color: string, weight = 2) {
|
||||
ctx.beginPath()
|
||||
points.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = weight
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function polygon(ctx: Ink, points: readonly Point[], fill: string, stroke?: string, weight = 3) {
|
||||
ctx.beginPath()
|
||||
points.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = fill
|
||||
ctx.fill()
|
||||
if (stroke) {
|
||||
ctx.strokeStyle = stroke
|
||||
ctx.lineWidth = weight
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function circle(ctx: Ink, x: number, y: number, radius: number, color: string, weight = 2) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = weight
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Measure tracking explicitly so every label stays inside its designated print area.
|
||||
function text(ctx: Ink, label: string, x: number, y: number, size: number,
|
||||
color: string, family = sans, tracking = 0, maxWidth = 860, weight = 'normal') {
|
||||
ctx.save()
|
||||
ctx.font = `${weight} ${size}px ${family}`
|
||||
ctx.fillStyle = color
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.textAlign = 'left'
|
||||
const letters = Array.from(label)
|
||||
const measured = letters.reduce((sum, letter) => sum + ctx.measureText(letter).width, 0)
|
||||
+ Math.max(0, letters.length - 1) * tracking
|
||||
const scale = Math.min(1, maxWidth / measured)
|
||||
ctx.translate(x, y)
|
||||
ctx.scale(scale, 1)
|
||||
let cursor = -measured / 2
|
||||
for (const letter of letters) {
|
||||
ctx.fillText(letter, cursor, 0)
|
||||
cursor += ctx.measureText(letter).width + tracking
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function cross(ctx: Ink, x: number, y: number, size: number, color: string, weight = 3) {
|
||||
line(ctx, [[x, y - size], [x, y + size]], color, weight)
|
||||
line(ctx, [[x - size * 0.64, y - size * 0.3], [x + size * 0.64, y - size * 0.3]], color, weight)
|
||||
}
|
||||
|
||||
function diamond(ctx: Ink, x: number, y: number, size: number, color: string) {
|
||||
polygon(ctx, [[x, y - size], [x + size, y], [x, y + size], [x - size, y]], color)
|
||||
}
|
||||
|
||||
function footer(ctx: Ink, back: boolean, color: string, y = 1195) {
|
||||
text(ctx, back ? 'AUTHORED EDITION / 001' : 'ONE COLLECTION',
|
||||
512, y, 22, color, sans, 3, 790)
|
||||
}
|
||||
|
||||
function pointedWindow(ctx: Ink, x: number, top: number, w: number, h: number) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - w / 2, top + h)
|
||||
ctx.lineTo(x - w / 2, top + h * 0.43)
|
||||
ctx.bezierCurveTo(x - w / 2, top + h * 0.2, x - w * 0.17, top + h * 0.06, x, top)
|
||||
ctx.bezierCurveTo(x + w * 0.17, top + h * 0.06, x + w / 2, top + h * 0.2, x + w / 2, top + h * 0.43)
|
||||
ctx.lineTo(x + w / 2, top + h)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function glass(ctx: Ink, x: number, top: number, w: number, h: number) {
|
||||
const lead = '#0b1b36'
|
||||
const pane = ['#204e77', '#347690', '#23385e', '#435a83', '#749597', '#315977']
|
||||
ctx.save()
|
||||
pointedWindow(ctx, x, top, w, h)
|
||||
ctx.clip()
|
||||
ctx.fillStyle = '#173858'
|
||||
ctx.fillRect(x - w / 2, top, w, h)
|
||||
|
||||
const centerY = top + h * 0.35
|
||||
const radius = w * 0.33
|
||||
// Radiating glass tesserae surround a twelve-petal rose and three lancets.
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const a = i * Math.PI / 12 - Math.PI / 2
|
||||
const b = (i + 1) * Math.PI / 12 - Math.PI / 2
|
||||
polygon(ctx, [
|
||||
[x + Math.cos(a) * radius, centerY + Math.sin(a) * radius],
|
||||
[x + Math.cos(a) * w * 1.6, centerY + Math.sin(a) * w * 1.6],
|
||||
[x + Math.cos(b) * w * 1.6, centerY + Math.sin(b) * w * 1.6],
|
||||
[x + Math.cos(b) * radius, centerY + Math.sin(b) * radius],
|
||||
], pane[i % pane.length]!, lead, 6)
|
||||
}
|
||||
circle(ctx, x, centerY, radius + 7, gold, 3)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
ctx.save()
|
||||
ctx.translate(x, centerY)
|
||||
ctx.rotate(i * Math.PI / 6)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -radius)
|
||||
ctx.quadraticCurveTo(radius * 0.4, -radius * 0.52, 0, -radius * 0.2)
|
||||
ctx.quadraticCurveTo(-radius * 0.4, -radius * 0.52, 0, -radius)
|
||||
ctx.fillStyle = pane[(i + 1) % pane.length]!
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = lead
|
||||
ctx.lineWidth = 5
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
circle(ctx, x, centerY, radius * 0.21, gold, 3)
|
||||
cross(ctx, x, centerY, radius * 0.13, '#e1cf99', 4)
|
||||
|
||||
const lancetTop = top + h * 0.62
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const lx = x + i * w * 0.27
|
||||
const ly = lancetTop + Math.abs(i) * 26
|
||||
const lw = w * 0.21
|
||||
const lh = top + h - ly - 20
|
||||
pointedWindow(ctx, lx, ly, lw, lh)
|
||||
ctx.fillStyle = i === 0 ? '#346b82' : '#1c3c61'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = lead
|
||||
ctx.lineWidth = 8
|
||||
ctx.stroke()
|
||||
pointedWindow(ctx, lx, ly + 10, lw - 14, lh - 17)
|
||||
ctx.strokeStyle = gold
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
for (let y = ly + 66; y < top + h - 45; y += 67) {
|
||||
polygon(ctx, [[lx, y - 24], [lx + lw * 0.3, y], [lx, y + 24], [lx - lw * 0.3, y]],
|
||||
i === 0 ? '#90a49c' : '#416c88', lead, 4)
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
pointedWindow(ctx, x, top, w, h)
|
||||
ctx.strokeStyle = gold
|
||||
ctx.lineWidth = 5
|
||||
ctx.stroke()
|
||||
pointedWindow(ctx, x, top - 17, w + 30, h + 30)
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function cathedral(ctx: Ink, back: boolean) {
|
||||
const ivory = '#e6ddc5'
|
||||
ctx.fillStyle = '#101e3a'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.strokeStyle = '#344767'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(77, 165, 870, 1080)
|
||||
for (const x of [92, 932]) {
|
||||
line(ctx, [[x, 185], [x, 1225]], gold, 1.5)
|
||||
for (const y of [190, 1220]) diamond(ctx, x, y, 5, gold)
|
||||
}
|
||||
text(ctx, 'SANCTIFICATION', 512, 231, back ? 48 : 61, ivory, serif, 1.2, 814)
|
||||
text(ctx, 'COLLECTOR SERIES', 512, 288, 21, gold, sans, 5)
|
||||
if (back) {
|
||||
glass(ctx, 512, 435, 392, 575)
|
||||
line(ctx, [[217, 680], [217, 1040], [402, 1040]], '#42577a')
|
||||
line(ctx, [[807, 680], [807, 1040], [622, 1040]], '#42577a')
|
||||
diamond(ctx, 217, 648, 7, gold)
|
||||
diamond(ctx, 807, 648, 7, gold)
|
||||
} else {
|
||||
glass(ctx, 512, 358, 626, 688)
|
||||
}
|
||||
line(ctx, [[260, 1127], [468, 1127]], gold, 1.5)
|
||||
diamond(ctx, 512, 1127, 7, gold)
|
||||
line(ctx, [[556, 1127], [764, 1127]], gold, 1.5)
|
||||
footer(ctx, back, ivory)
|
||||
}
|
||||
|
||||
function leaf(ctx: Ink, x: number, y: number, angle: number, size: number, fill: string, ink: string) {
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate(angle)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.bezierCurveTo(-size * 0.55, -size * 0.3, -size * 0.4, -size * 0.85, 0, -size)
|
||||
ctx.bezierCurveTo(size * 0.52, -size * 0.62, size * 0.4, -size * 0.24, 0, 0)
|
||||
ctx.fillStyle = fill
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = ink
|
||||
ctx.lineWidth = 1.7
|
||||
ctx.stroke()
|
||||
line(ctx, [[0, 0], [0, -size * 0.78]], ink, 1.3)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function vine(ctx: Ink, x: number, top: number, length: number, mirror: number) {
|
||||
const ink = '#796840'
|
||||
ctx.save()
|
||||
ctx.translate(x, top)
|
||||
ctx.scale(mirror, 1)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
for (let y = 0; y < length; y += 120) {
|
||||
ctx.bezierCurveTo(-28, y + 34, 28, y + 86, 0, Math.min(y + 120, length))
|
||||
}
|
||||
ctx.strokeStyle = ink
|
||||
ctx.lineWidth = 3
|
||||
ctx.stroke()
|
||||
for (let y = 38; y < length - 24; y += 60) {
|
||||
const direction = Math.floor(y / 60) % 2 === 0 ? -1 : 1
|
||||
leaf(ctx, 0, y, direction * 0.95, 38, '#b5af88', ink)
|
||||
leaf(ctx, 0, y + 18, -direction * 0.85, 27, '#d5c7a0', ink)
|
||||
if (y % 180 === 38) {
|
||||
const bx = direction * 32
|
||||
line(ctx, [[0, y], [bx, y - 19]], ink, 1.5)
|
||||
for (const [dx, dy] of [[-6, -3], [6, -3], [0, -13]]) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(bx + dx!, y - 19 + dy!, 5, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#853e45'
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function manuscript(ctx: Ink, back: boolean) {
|
||||
const burgundy = '#672b39'
|
||||
const ink = '#493a30'
|
||||
ctx.fillStyle = '#eee3c9'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
// A regular, fine paper tooth reads as print rather than baked illumination.
|
||||
ctx.fillStyle = '#ded1b7'
|
||||
for (let y = 111; y < 1315; y += 9) {
|
||||
for (let x = (y % 2) * 5; x < width; x += 13) ctx.fillRect(x, y, 1, 1)
|
||||
}
|
||||
ctx.strokeStyle = burgundy
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeRect(78, 166, 868, 1072)
|
||||
ctx.strokeStyle = '#a18b60'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeRect(91, 180, 842, 1044)
|
||||
vine(ctx, 139, 230, 900, 1)
|
||||
vine(ctx, 885, 230, 900, -1)
|
||||
for (const [x, y] of [[105, 194], [919, 194], [105, 1210], [919, 1210]]) {
|
||||
diamond(ctx, x!, y!, 8, burgundy)
|
||||
}
|
||||
text(ctx, 'COLLECTOR SERIES', 512, 254, 21, burgundy, sans, 4, 680)
|
||||
const emblemY = back ? 562 : 438
|
||||
ctx.fillStyle = burgundy
|
||||
ctx.fillRect(448, emblemY - 75, 128, 150)
|
||||
ctx.strokeStyle = '#c9af72'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(457, emblemY - 66, 110, 132)
|
||||
cross(ctx, 512, emblemY, 38, '#eee0b9', 6)
|
||||
for (const x of [470, 554]) {
|
||||
leaf(ctx, x, emblemY + 47, x < 512 ? -0.28 : 0.28, 34, '#bea16b', '#d8c397')
|
||||
}
|
||||
text(ctx, 'SANCTIFICATION', 512, back ? 746 : 651, back ? 49 : 56,
|
||||
burgundy, serif, 0.3, 700)
|
||||
line(ctx, [[222, back ? 805 : 712], [802, back ? 805 : 712]], '#a18b60', 1.5)
|
||||
if (!back) {
|
||||
ctx.save()
|
||||
ctx.translate(512, 976)
|
||||
for (const direction of [-1, 1]) {
|
||||
ctx.save()
|
||||
ctx.scale(direction, 1)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 28)
|
||||
ctx.bezierCurveTo(70, 22, 142, -58, 162, -150)
|
||||
ctx.strokeStyle = '#796840'
|
||||
ctx.lineWidth = 2.5
|
||||
ctx.stroke()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = 36 + i * 26
|
||||
const y = 16 - i * 27
|
||||
leaf(ctx, x, y, -0.75, 39, '#b5af88', '#796840')
|
||||
leaf(ctx, x, y, 0.8, 31, '#d5c7a0', '#796840')
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
diamond(ctx, 0, 28, 6, burgundy)
|
||||
ctx.restore()
|
||||
} else {
|
||||
diamond(ctx, 512, 954, 8, burgundy)
|
||||
}
|
||||
footer(ctx, back, ink, 1174)
|
||||
}
|
||||
|
||||
function modern(ctx: Ink, back: boolean) {
|
||||
const ivory = '#f0e9d5'
|
||||
const muted = '#a7b6a0'
|
||||
ctx.fillStyle = '#123b30'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
// The modern edition uses an editorial grid, not an ornamental frame.
|
||||
line(ctx, [[104, 184], [920, 184]], '#66816a', 1.5)
|
||||
text(ctx, 'COLLECTOR SERIES', 512, 232, 21, ivory, sans, 5.5, 800)
|
||||
const cy = back ? 580 : 465
|
||||
circle(ctx, 512, cy, 39, gold, 2)
|
||||
cross(ctx, 512, cy, 23, ivory, 3)
|
||||
line(ctx, [[512, cy - 56], [512, cy - 49]], gold)
|
||||
line(ctx, [[512, cy + 49], [512, cy + 56]], gold)
|
||||
text(ctx, 'SANCTIFICATION', 512, back ? 757 : 684, back ? 51 : 65,
|
||||
ivory, sans, back ? 1.2 : 0.8, 834, '600')
|
||||
line(ctx, [[480, back ? 823 : 769], [544, back ? 823 : 769]], gold, 3)
|
||||
if (!back) {
|
||||
for (const x of [490, 512, 534]) {
|
||||
ctx.fillStyle = muted
|
||||
ctx.fillRect(x - 2, 1040, 4, 4)
|
||||
}
|
||||
}
|
||||
line(ctx, [[104, 1131], [920, 1131]], '#66816a', 1.5)
|
||||
footer(ctx, back, ivory, 1193)
|
||||
}
|
||||
|
||||
function seams(ctx: Ink) {
|
||||
ctx.fillStyle = gold
|
||||
ctx.fillRect(0, 0, width, 100)
|
||||
ctx.fillRect(0, 1315, width, 85)
|
||||
ctx.strokeStyle = '#a38450'
|
||||
ctx.lineWidth = 1
|
||||
for (let x = 8; x < width; x += 12) {
|
||||
line(ctx, [[x, 7], [x, 23]], '#a38450', 1)
|
||||
line(ctx, [[x, 78], [x, 94]], '#a38450', 1)
|
||||
line(ctx, [[x, 1329], [x, 1386]], '#a38450', 1)
|
||||
}
|
||||
line(ctx, [[0, 99], [1024, 99]], '#745c35', 2)
|
||||
line(ctx, [[0, 1316], [1024, 1316]], '#745c35', 2)
|
||||
text(ctx, 'PULL TO OPEN', 500, 51, 24, '#252b23', sans, 3.5, 620, 'bold')
|
||||
line(ctx, [[706, 51], [778, 51], [765, 39]], '#252b23', 3)
|
||||
line(ctx, [[778, 51], [765, 63]], '#252b23', 3)
|
||||
}
|
||||
|
||||
export function createPackTexture(style: PackStyle, back = false): THREE.CanvasTexture {
|
||||
if (!packStyles.includes(style)) throw new Error(`Unknown pack style: ${String(style)}`)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d', { alpha: false })
|
||||
if (!ctx) throw new Error('Cannot create pack texture: Canvas 2D context is unavailable')
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
switch (style) {
|
||||
case 'Cathedral glass': cathedral(ctx, back); break
|
||||
case 'Illuminated manuscript': manuscript(ctx, back); break
|
||||
case 'Quiet modern': modern(ctx, back); break
|
||||
}
|
||||
seams(ctx)
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.name = `${style} — ${back ? 'back' : 'front'}`
|
||||
return texture
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three'
|
||||
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
|
||||
import { createFoilWrapper } from './foilWrapper'
|
||||
import type { PackStyle } from './packDesigns'
|
||||
import {
|
||||
applyEdgeMaterialControls,
|
||||
applyMaterialControls,
|
||||
@@ -9,19 +10,38 @@ import {
|
||||
type SubstrateName,
|
||||
} from './cardMaterial'
|
||||
|
||||
export type PackFixture = 'David' | 'Timothy'
|
||||
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
|
||||
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
|
||||
|
||||
export const packContents: readonly {
|
||||
fixture: PackFixture
|
||||
export interface PackCardSpec {
|
||||
fixture: string
|
||||
finish: FinishName
|
||||
substrate: SubstrateName
|
||||
}[] = [
|
||||
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
|
||||
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
|
||||
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
|
||||
]
|
||||
}
|
||||
|
||||
export const packSize = 10
|
||||
export const packFinishes: readonly FinishName[] = ['Printed ink', 'Foil', 'Holographic']
|
||||
export const packSubstrates: readonly SubstrateName[] = ['Paper', 'Linen', 'Metal', 'Wood']
|
||||
|
||||
function randomChoice<T>(values: readonly T[], random: () => number) {
|
||||
const roll = random()
|
||||
if (!Number.isFinite(roll) || roll < 0 || roll >= 1) {
|
||||
throw new Error(`Random source must return a number in [0, 1); received ${roll}`)
|
||||
}
|
||||
return values[Math.floor(roll * values.length)]
|
||||
}
|
||||
|
||||
export function rollPackContents(
|
||||
fixtures: readonly string[],
|
||||
random: () => number = Math.random,
|
||||
): PackCardSpec[] {
|
||||
if (!fixtures.length) throw new Error('Cannot roll a pack without valid card artwork')
|
||||
return Array.from({ length: packSize }, () => ({
|
||||
fixture: randomChoice(fixtures, random),
|
||||
finish: randomChoice(packFinishes, random),
|
||||
substrate: randomChoice(packSubstrates, random),
|
||||
}))
|
||||
}
|
||||
|
||||
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
|
||||
const stackDepthSpacing = 0.055
|
||||
@@ -34,7 +54,8 @@ const inspectionCameraRetreat = inspectionDepth - 0.85
|
||||
|
||||
interface PackOptions {
|
||||
canvas: HTMLCanvasElement
|
||||
textures: Record<PackFixture, { artwork: THREE.Texture; mask: THREE.Texture }>
|
||||
contents: readonly PackCardSpec[]
|
||||
textures: ReadonlyMap<string, { artwork: THREE.Texture; mask: THREE.Texture }>
|
||||
backMaterial: THREE.Material
|
||||
normalMap: THREE.Texture
|
||||
environment: THREE.CubeTexture
|
||||
@@ -94,6 +115,7 @@ export class PackOpening {
|
||||
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
||||
state: PackState = 'sealed'
|
||||
paused = false
|
||||
readonly contents: readonly PackCardSpec[]
|
||||
private readonly options: PackOptions
|
||||
private readonly wrapper = createFoilWrapper()
|
||||
private readonly cards
|
||||
@@ -119,13 +141,16 @@ export class PackOpening {
|
||||
private pinchDistance: number | undefined
|
||||
|
||||
constructor(options: PackOptions) {
|
||||
if (!options.contents.length) throw new Error('Pack must contain at least one card')
|
||||
this.options = options
|
||||
this.contents = options.contents.map(spec => ({ ...spec }))
|
||||
this.root.name = 'PACK_PROTOTYPE'
|
||||
this.root.position.y = 0.22
|
||||
this.root.visible = false
|
||||
this.root.add(this.wrapper.root)
|
||||
this.cards = packContents.map((spec) => {
|
||||
const textures = options.textures[spec.fixture]
|
||||
this.cards = this.contents.map((spec) => {
|
||||
const textures = options.textures.get(spec.fixture)
|
||||
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`)
|
||||
const material = createCardMaterial(
|
||||
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
|
||||
)
|
||||
@@ -144,16 +169,16 @@ export class PackOpening {
|
||||
|
||||
get view() {
|
||||
const number = `${this.index + 1} / ${this.cards.length}`
|
||||
const spec = packContents[this.index]
|
||||
const spec = this.contents[this.index]
|
||||
const views: Record<PackState, { action: string; status: string; hint: string }> = {
|
||||
sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' },
|
||||
sealed: { action: 'Tear open', status: `Sealed · ${this.cards.length} cards`, hint: 'Drag the gold top seam to the right · or use Tear open' },
|
||||
opening: {
|
||||
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
|
||||
status: this.openingProgress < 0.38
|
||||
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
|
||||
: this.openingProgress < 0.52 ? 'Strip curling away'
|
||||
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
|
||||
: this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack',
|
||||
: this.openingProgress < 0.87 ? `Sliding out ${this.cards.length} cards` : 'Settling the face-down stack',
|
||||
hint: this.openingProgress < 0.38
|
||||
? 'Pull right along the seam · release to hold · Finish opening resumes'
|
||||
: 'Touch or wheel pauses motion · Skip finishes this step',
|
||||
@@ -168,7 +193,7 @@ export class PackOpening {
|
||||
hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue',
|
||||
},
|
||||
advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' },
|
||||
complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' },
|
||||
complete: { action: 'Pack complete', status: `Complete · all ${this.cards.length} cards inspected`, hint: 'Choose New pack for another independent roll' },
|
||||
}
|
||||
return this.paused && this.motion
|
||||
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
|
||||
@@ -186,9 +211,16 @@ export class PackOpening {
|
||||
}
|
||||
}
|
||||
|
||||
get style() { return this.wrapper.style }
|
||||
|
||||
setStyle(style: PackStyle) {
|
||||
this.wrapper.setStyle(style)
|
||||
this.options.onChange()
|
||||
}
|
||||
|
||||
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
|
||||
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
|
||||
const textures = new Set<THREE.Texture>()
|
||||
const textures = new Set<THREE.Texture>(this.wrapper.textures)
|
||||
if (scene.environment) textures.add(scene.environment)
|
||||
this.root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return
|
||||
@@ -209,7 +241,7 @@ export class PackOpening {
|
||||
await renderer.compileAsync(this.root, this.camera, scene)
|
||||
try {
|
||||
this.cards.forEach((card, index) => {
|
||||
applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 })
|
||||
applyEdgeMaterialControls(card.edge, { ...this.contents[index], condition: 1 })
|
||||
})
|
||||
await renderer.compileAsync(this.root, this.camera, scene)
|
||||
} finally {
|
||||
@@ -311,8 +343,9 @@ export class PackOpening {
|
||||
this.updateCamera()
|
||||
this.cards.forEach((card, index) => {
|
||||
const target = this.stackPose(index)
|
||||
const stagger = this.cards.length === 1 ? 0 : index / (this.cards.length - 1) * 0.06
|
||||
// All cards clear the mouth before the pouch moves aside. Depth spacing never changes.
|
||||
target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling)
|
||||
target.position.y += 4.4 * smooth(value, 0.62 + stagger, 0.8 + stagger) * (1 - settling)
|
||||
setPose(card.object, target)
|
||||
})
|
||||
const aside = smooth(value, 0.83, 0.87)
|
||||
@@ -340,9 +373,9 @@ export class PackOpening {
|
||||
})
|
||||
if (state === 'inspecting' && !this.revealed.has(this.index)) {
|
||||
this.revealed.add(this.index)
|
||||
applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 })
|
||||
applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 })
|
||||
this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
|
||||
detail: { index: this.index + 1, ...packContents[this.index] },
|
||||
detail: { index: this.index + 1, ...this.contents[this.index] },
|
||||
}))
|
||||
}
|
||||
if (state === 'complete' && !this.completed) {
|
||||
|
||||
@@ -20,7 +20,8 @@ body,
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
select,
|
||||
input {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -108,6 +109,7 @@ h1 {
|
||||
}
|
||||
|
||||
select,
|
||||
input,
|
||||
button {
|
||||
height: 34px;
|
||||
border: 1px solid rgba(227, 215, 181, 0.2);
|
||||
@@ -116,7 +118,8 @@ button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select {
|
||||
select,
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 28px 0 10px;
|
||||
@@ -128,17 +131,39 @@ button {
|
||||
}
|
||||
|
||||
button:hover,
|
||||
select:hover {
|
||||
select:hover,
|
||||
input:hover {
|
||||
border-color: rgba(218, 186, 102, 0.62);
|
||||
background: #202630;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible {
|
||||
select:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #d6b860;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.catalog-state {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: #777f8a;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.catalog-state.has-errors {
|
||||
color: #e59a8d;
|
||||
}
|
||||
|
||||
.toolbar label:has(.catalog-state) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewport-shell {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
@@ -306,12 +331,18 @@ button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pack-mode .toolbar > :not(:first-child) {
|
||||
.toolbar .pack-style,
|
||||
.pack-mode .toolbar > :not(:first-child):not(.pack-style) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pack-mode .toolbar .pack-style {
|
||||
display: grid;
|
||||
flex-basis: 200px;
|
||||
}
|
||||
|
||||
.pack-mode .toolbar {
|
||||
flex: 0 1 180px;
|
||||
flex: 0 1 400px;
|
||||
}
|
||||
|
||||
.pack-mode .hint {
|
||||
|
||||
41
spikes/card-harness/vite.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { cardArtRoot, scanCardCatalog, writeCardCatalog } from './scripts/card-catalog.mjs'
|
||||
|
||||
function cardCatalogPlugin() {
|
||||
return {
|
||||
name: 'card-catalog',
|
||||
async buildStart() {
|
||||
const catalog = await writeCardCatalog()
|
||||
if (!catalog.cards.length || catalog.errors.length) {
|
||||
this.error(`Invalid card catalog:\n${catalog.errors.join('\n') || 'No valid card pairs found'}`)
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/__card_catalog', async (_request, response) => {
|
||||
try {
|
||||
const catalog = await scanCardCatalog()
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.end(JSON.stringify(catalog))
|
||||
} catch (error) {
|
||||
response.statusCode = 500
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
}
|
||||
})
|
||||
server.watcher.add(cardArtRoot)
|
||||
server.watcher.on('all', (_event, path) => {
|
||||
if (path.startsWith(cardArtRoot) && path.toLowerCase().endsWith('.png')) {
|
||||
server.ws.send({ type: 'custom', event: 'card-catalog:update' })
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [cardCatalogPlugin()],
|
||||
})
|
||||