card harness dynamic changes

This commit is contained in:
2026-09-11 21:19:08 -07:00
parent 8fcda5f2b7
commit 56f010a71e
25 changed files with 1055 additions and 126 deletions

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View 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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View 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
}
}

View 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')))
})

View File

@@ -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)

View File

@@ -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 } }

View 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
}

View File

@@ -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 } }
}

View File

@@ -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

View 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
}

View File

@@ -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) {

View File

@@ -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 {

View 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()],
})