card harness dynamic changes
@@ -113,7 +113,7 @@ Changing a light type, color, position, intensity, environment contribution, or
|
|||||||
| Control | Default | Available range |
|
| Control | Default | Available range |
|
||||||
|---|---:|---:|
|
|---|---:|---:|
|
||||||
| Finish | Holographic | Printed ink / Foil / Holographic |
|
| 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 |
|
| Finish strength | 0.60 | 0-1 |
|
||||||
| Roughness | 0.23 | 0.05-0.8 |
|
| Roughness | 0.23 | 0.05-0.8 |
|
||||||
| Surface detail | 0.14 | 0-0.45 |
|
| 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
|
## Included proof scope
|
||||||
|
|
||||||
- David printed-ink, foil, and holographic presets
|
- 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
|
- Timothy as the second-artwork fixture
|
||||||
- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy
|
- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy
|
||||||
- Procedural rounded-card geometry generated from shared physical dimensions
|
- 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
|
## Tear-open foil pack prototype
|
||||||
|
|
||||||
Choose **Pack** in the Mode selector (expand **Controls** first on mobile).
|
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:
|
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.**
|
**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
|
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
|
[`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.
|
is required: selecting Linen applies it in Inspect, Lab, and the authored pack.
|
||||||
Plastic has a smooth clear-coat
|
The legacy Plastic shader recipe remains readable by older saved experiments but is
|
||||||
highlight even in Printed ink mode. Wood uses raised longitudinal grain with
|
not offered in the UI or randomized packs. Wood uses raised longitudinal grain with
|
||||||
bounded procedural parallax, stronger relief, and a restrained warm tint.
|
bounded procedural parallax, stronger relief, and a restrained warm tint.
|
||||||
The artwork stays anchored while grain shading and tint shift together.
|
The artwork stays anchored while grain shading and tint shift together.
|
||||||
Metal retains its reflective underprint and uses luminance-driven recesses:
|
Metal retains its reflective underprint and uses luminance-driven recesses:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"assets": "node scripts/generate-reference-assets.mjs",
|
"assets": "node scripts/generate-reference-assets.mjs",
|
||||||
|
"catalog": "node scripts/card-catalog.mjs",
|
||||||
"export:blender": "node scripts/export-card-mesh.mjs",
|
"export:blender": "node scripts/export-card-mesh.mjs",
|
||||||
"test": "node --test scripts/*.test.mjs",
|
"test": "node --test scripts/*.test.mjs",
|
||||||
"predev": "npm run assets",
|
"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 { createFoilWrapper } = await import('../src/foilWrapper.ts')
|
||||||
const { PackOpening } = await import('../src/packOpening.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.
|
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
|
||||||
const originalHashes = [
|
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])
|
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) {
|
function setupPack(t, reducedMotion = false) {
|
||||||
installDOM(reducedMotion)
|
installDOM(reducedMotion)
|
||||||
let now = 1000
|
let now = 1000
|
||||||
@@ -158,6 +202,39 @@ test('interruption and mode changes preserve progress without deforming in point
|
|||||||
assert.equal(pack.state, 'stackReady')
|
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) => {
|
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
|
||||||
const { pack, canvas, step } = setupPack(t)
|
const { pack, canvas, step } = setupPack(t)
|
||||||
const seam = pack.seamScreenBounds()
|
const seam = pack.seamScreenBounds()
|
||||||
@@ -267,7 +344,7 @@ test('GPU preparation uploads unique textures and compiles hidden fronts and bot
|
|||||||
finishCompile()
|
finishCompile()
|
||||||
await preparation
|
await preparation
|
||||||
assert.equal(uploaded.length, new Set(uploaded).size)
|
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)) {
|
for (const { artwork, mask } of Object.values(pack.options.textures)) {
|
||||||
assert.ok(uploaded.includes(artwork))
|
assert.ok(uploaded.includes(artwork))
|
||||||
assert.ok(uploaded.includes(mask))
|
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(backTexture))
|
||||||
assert.ok(uploaded.includes(pack.options.normalMap))
|
assert.ok(uploaded.includes(pack.options.normalMap))
|
||||||
assert.ok(uploaded.includes(scene.environment))
|
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(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.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
|
||||||
assert.equal(geometryHash(pack.wrapper), before)
|
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.add(object.material)
|
||||||
})
|
})
|
||||||
materials.delete(pack.options.backMaterial)
|
materials.delete(pack.options.backMaterial)
|
||||||
const resources = [...geometries, ...materials,
|
const resources = [...geometries, ...materials, ...pack.wrapper.textures]
|
||||||
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
|
|
||||||
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
|
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
|
||||||
pack.dispose()
|
pack.dispose()
|
||||||
assert.equal(pack.root.parent, null)
|
assert.equal(pack.root.parent, null)
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export class TestCanvas extends EventTarget {
|
|||||||
return {
|
return {
|
||||||
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
|
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
|
||||||
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
|
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 } }
|
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 * as THREE from 'three'
|
||||||
|
import { createPackTexture, defaultPackStyle, packStyles, type PackStyle } from './packDesigns'
|
||||||
|
|
||||||
const halfWidth = 1.68
|
const halfWidth = 1.68
|
||||||
const bottom = -2.24
|
const bottom = -2.24
|
||||||
@@ -7,50 +8,6 @@ const top = 2.28
|
|||||||
const midDepth = -0.09
|
const midDepth = -0.09
|
||||||
const smooth = THREE.MathUtils.smoothstep
|
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.
|
* 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.
|
* All deformation is evaluated from immutable UVs, not accumulated frame deltas.
|
||||||
@@ -58,13 +15,37 @@ function wrapperTexture(back = false) {
|
|||||||
export function createFoilWrapper() {
|
export function createFoilWrapper() {
|
||||||
const root = new THREE.Group()
|
const root = new THREE.Group()
|
||||||
root.name = 'PACK_FOIL_WRAPPER'
|
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({
|
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()
|
const backMaterial = frontMaterial.clone()
|
||||||
backMaterial.map = wrapperTexture(true)
|
backMaterial.map = initialDesign.back
|
||||||
const stripMaterial = frontMaterial.clone()
|
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 sheets = [1, -1].map((side) => {
|
||||||
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
|
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
|
||||||
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
|
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
|
||||||
@@ -176,9 +157,8 @@ export function createFoilWrapper() {
|
|||||||
function dispose() {
|
function dispose() {
|
||||||
for (const { mesh } of sheets) mesh.geometry.dispose()
|
for (const { mesh } of sheets) mesh.geometry.dispose()
|
||||||
stripGeometry.dispose()
|
stripGeometry.dispose()
|
||||||
frontTexture.dispose()
|
for (const texture of textures) texture.dispose()
|
||||||
backMaterial.map!.dispose()
|
|
||||||
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.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,
|
type SubstrateName,
|
||||||
} from './cardMaterial'
|
} from './cardMaterial'
|
||||||
import { createCardGeometry } from './cardGeometry'
|
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 AppMode = 'Inspect' | 'Lab' | 'Pack'
|
||||||
type ManipulationTarget = 'Card' | 'Camera'
|
type ManipulationTarget = 'Card' | 'Camera'
|
||||||
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
|
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
|
||||||
@@ -50,16 +52,10 @@ interface ExperimentSnapshot {
|
|||||||
orbitTarget: [number, number, number]
|
orbitTarget: [number, number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixtureAssets: Record<FixtureName, { artwork: string; mask: string }> = {
|
let cardCatalog = await fetchCardCatalog()
|
||||||
David: {
|
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs')
|
||||||
artwork: '/reference/david-front.png',
|
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card]))
|
||||||
mask: '/reference/david-finish-mask.png',
|
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name
|
||||||
},
|
|
||||||
Timothy: {
|
|
||||||
artwork: '/reference/timothy-front.png',
|
|
||||||
mask: '/reference/timothy-finish-mask.png',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
|
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
|
||||||
texture.colorSpace = colorSpace
|
texture.colorSpace = colorSpace
|
||||||
@@ -93,12 +89,16 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<option>Pack</option>
|
<option>Pack</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Card
|
<label class="pack-style">Pack style
|
||||||
<select id="fixture">
|
<select id="pack-style" title="Change the wrapper only; use Restart to compare sealed packs" disabled>
|
||||||
<option>David</option>
|
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
|
||||||
<option>Timothy</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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
|
<label>Finish
|
||||||
<select id="finish">
|
<select id="finish">
|
||||||
<option>Holographic</option>
|
<option>Holographic</option>
|
||||||
@@ -110,7 +110,6 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<select id="substrate">
|
<select id="substrate">
|
||||||
<option>Paper</option>
|
<option>Paper</option>
|
||||||
<option>Linen</option>
|
<option>Linen</option>
|
||||||
<option>Plastic</option>
|
|
||||||
<option>Metal</option>
|
<option>Metal</option>
|
||||||
<option>Wood</option>
|
<option>Wood</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -159,7 +158,7 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<span id="pack-status" role="status" aria-live="polite"></span>
|
<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>
|
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
|
||||||
<div class="pack-actions">
|
<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-primary" type="button">Open pack</button>
|
||||||
<button id="pack-skip" type="button">Skip</button>
|
<button id="pack-skip" type="button">Skip</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +183,9 @@ const hintElement = document.querySelector<HTMLDivElement>('#hint')!
|
|||||||
const topbar = document.querySelector<HTMLElement>('.topbar')!
|
const topbar = document.querySelector<HTMLElement>('.topbar')!
|
||||||
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
|
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
|
||||||
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
|
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 finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
|
||||||
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
|
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
|
||||||
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
|
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 packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
|
||||||
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
|
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
|
||||||
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
|
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) {
|
function setControlsCollapsed(collapsed: boolean) {
|
||||||
topbar.classList.toggle('controls-collapsed', collapsed)
|
topbar.classList.toggle('controls-collapsed', collapsed)
|
||||||
@@ -270,8 +288,9 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
|
|||||||
backTexture.colorSpace = THREE.SRGBColorSpace
|
backTexture.colorSpace = THREE.SRGBColorSpace
|
||||||
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
|
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
|
||||||
|
|
||||||
const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork)
|
const initialAsset = fixtureAssets.get(defaultFixture)!
|
||||||
const initialMask = await loader.loadAsync(fixtureAssets.David.mask)
|
const initialArtwork = await loader.loadAsync(initialAsset.artwork)
|
||||||
|
const initialMask = await loader.loadAsync(initialAsset.mask)
|
||||||
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
|
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
|
||||||
configureFrontTexture(initialMask, THREE.NoColorSpace)
|
configureFrontTexture(initialMask, THREE.NoColorSpace)
|
||||||
|
|
||||||
@@ -413,7 +432,7 @@ surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).na
|
|||||||
updateMaterial()
|
updateMaterial()
|
||||||
updateStatus()
|
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()
|
pauseScriptedMotion()
|
||||||
substrateSelect.value = value
|
substrateSelect.value = value
|
||||||
updateMaterial()
|
updateMaterial()
|
||||||
@@ -515,7 +534,7 @@ const assetActions = {
|
|||||||
loadMask: () => maskFileInput.click(),
|
loadMask: () => maskFileInput.click(),
|
||||||
restoreFixture: () => {
|
restoreFixture: () => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
void loadFixture(fixtureSelect.value as FixtureName)
|
void loadFixture(fixtureSelect.value)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const assetFolder = gui.addFolder('Assets')
|
const assetFolder = gui.addFolder('Assets')
|
||||||
@@ -546,7 +565,7 @@ gui.hide()
|
|||||||
|
|
||||||
let mode: AppMode = 'Inspect'
|
let mode: AppMode = 'Inspect'
|
||||||
let textureRequest = 0
|
let textureRequest = 0
|
||||||
let activeFixture: FixtureName = 'David'
|
let activeFixture: FixtureName = defaultFixture
|
||||||
let customArtworkName: string | undefined
|
let customArtworkName: string | undefined
|
||||||
let customMaskName: string | undefined
|
let customMaskName: string | undefined
|
||||||
let dragging = false
|
let dragging = false
|
||||||
@@ -567,6 +586,8 @@ let pack: PackOpening | undefined
|
|||||||
let packLoading = false
|
let packLoading = false
|
||||||
let packUIDirty = false
|
let packUIDirty = false
|
||||||
let packError: string | undefined
|
let packError: string | undefined
|
||||||
|
let packTextures: THREE.Texture[] = []
|
||||||
|
let selectedPackStyle = defaultPackStyle
|
||||||
|
|
||||||
function updateMaterial() {
|
function updateMaterial() {
|
||||||
applyMaterialControls(frontMaterial, controls)
|
applyMaterialControls(frontMaterial, controls)
|
||||||
@@ -696,12 +717,16 @@ function updatePackUI() {
|
|||||||
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
||||||
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
||||||
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
|
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(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')
|
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
|
||||||
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
||||||
setIfChanged(packStatusElement, 'textContent', status)
|
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')
|
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
|
if (target[key] !== value) target[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function preparePack() {
|
async function preparePack(newRoll = false) {
|
||||||
if (pack) {
|
if (pack && !newRoll) {
|
||||||
pack.syncLighting(frontMaterial)
|
pack.syncLighting(frontMaterial)
|
||||||
pack.setActive(true)
|
pack.setActive(true)
|
||||||
updatePackUI()
|
updatePackUI()
|
||||||
@@ -719,13 +744,26 @@ async function preparePack() {
|
|||||||
if (packLoading) return
|
if (packLoading) return
|
||||||
packLoading = true
|
packLoading = true
|
||||||
packError = undefined
|
packError = undefined
|
||||||
|
if (newRoll && pack) {
|
||||||
|
selectedPackStyle = pack.style
|
||||||
|
pack.dispose()
|
||||||
|
pack = undefined
|
||||||
|
for (const texture of packTextures) texture.dispose()
|
||||||
|
packTextures = []
|
||||||
|
}
|
||||||
updatePackUI()
|
updatePackUI()
|
||||||
let preparedPack: PackOpening | undefined
|
let preparedPack: PackOpening | undefined
|
||||||
let textures: THREE.Texture[] = []
|
let textures: THREE.Texture[] = []
|
||||||
try {
|
try {
|
||||||
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
|
const contents = rollPackContents([...fixtureAssets.keys()])
|
||||||
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
|
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
|
||||||
fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask]
|
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 results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
|
||||||
const failure = results.find((result) => result.status === 'rejected')
|
const failure = results.find((result) => result.status === 'rejected')
|
||||||
if (failure?.status === 'rejected') {
|
if (failure?.status === 'rejected') {
|
||||||
@@ -739,18 +777,21 @@ async function preparePack() {
|
|||||||
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
|
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
|
||||||
return result.value
|
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({
|
preparedPack = new PackOpening({
|
||||||
canvas,
|
canvas,
|
||||||
textures: {
|
contents,
|
||||||
David: { artwork: textures[0], mask: textures[1] },
|
textures: selectedTextures,
|
||||||
Timothy: { artwork: textures[2], mask: textures[3] },
|
|
||||||
},
|
|
||||||
backMaterial,
|
backMaterial,
|
||||||
normalMap,
|
normalMap,
|
||||||
environment: activeEnvironment,
|
environment: activeEnvironment,
|
||||||
lightPosition,
|
lightPosition,
|
||||||
onChange: () => { packUIDirty = true },
|
onChange: () => { packUIDirty = true },
|
||||||
})
|
})
|
||||||
|
preparedPack.setStyle(selectedPackStyle)
|
||||||
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
|
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
|
||||||
let preparedEnvironment: THREE.Texture | null
|
let preparedEnvironment: THREE.Texture | null
|
||||||
let preparedLightType: LightType
|
let preparedLightType: LightType
|
||||||
@@ -765,6 +806,7 @@ async function preparePack() {
|
|||||||
preparedPack.setActive(mode === 'Pack')
|
preparedPack.setActive(mode === 'Pack')
|
||||||
scene.add(preparedPack.root)
|
scene.add(preparedPack.root)
|
||||||
pack = preparedPack
|
pack = preparedPack
|
||||||
|
packTextures = textures
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
preparedPack?.dispose()
|
preparedPack?.dispose()
|
||||||
for (const texture of textures) texture.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 {
|
function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
|
||||||
if (typeof value !== 'object' || value === null) return false
|
if (typeof value !== 'object' || value === null) return false
|
||||||
const snapshot = value as Record<string, unknown>
|
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 =
|
const finishValid =
|
||||||
snapshot.finish === 'Printed ink' ||
|
snapshot.finish === 'Printed ink' ||
|
||||||
snapshot.finish === 'Foil' ||
|
snapshot.finish === 'Foil' ||
|
||||||
@@ -986,6 +1028,9 @@ function captureExperimentPng() {
|
|||||||
|
|
||||||
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
|
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
|
if (!fixtureAssets.has(snapshot.fixture)) {
|
||||||
|
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
|
||||||
|
}
|
||||||
fixtureSelect.value = snapshot.fixture
|
fixtureSelect.value = snapshot.fixture
|
||||||
await loadFixture(snapshot.fixture)
|
await loadFixture(snapshot.fixture)
|
||||||
controls.finish = snapshot.finish
|
controls.finish = snapshot.finish
|
||||||
@@ -1072,7 +1117,8 @@ function applyInspectionPose(pose: InspectionPose) {
|
|||||||
|
|
||||||
function resetView() {
|
function resetView() {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined
|
const resetFixture = activeFixture !== defaultFixture ||
|
||||||
|
customArtworkName !== undefined || customMaskName !== undefined
|
||||||
rotationTarget.set(-0.06, 0.12, 0)
|
rotationTarget.set(-0.06, 0.12, 0)
|
||||||
cardRoot.rotation.copy(rotationTarget)
|
cardRoot.rotation.copy(rotationTarget)
|
||||||
camera.position.set(0, 0, 8.2)
|
camera.position.set(0, 0, 8.2)
|
||||||
@@ -1080,7 +1126,7 @@ function resetView() {
|
|||||||
orbit.update()
|
orbit.update()
|
||||||
modeSelect.value = 'Inspect'
|
modeSelect.value = 'Inspect'
|
||||||
controls.target = 'Card'
|
controls.target = 'Card'
|
||||||
fixtureSelect.value = 'David'
|
fixtureSelect.value = defaultFixture
|
||||||
finishSelect.value = 'Holographic'
|
finishSelect.value = 'Holographic'
|
||||||
substrateSelect.value = 'Paper'
|
substrateSelect.value = 'Paper'
|
||||||
poseSelect.value = 'Front'
|
poseSelect.value = 'Front'
|
||||||
@@ -1096,7 +1142,7 @@ function resetView() {
|
|||||||
updateEdgeMaterial()
|
updateEdgeMaterial()
|
||||||
updateMode()
|
updateMode()
|
||||||
if (resetFixture) {
|
if (resetFixture) {
|
||||||
void loadFixture('David')
|
void loadFixture(defaultFixture)
|
||||||
} else {
|
} else {
|
||||||
updateStatus()
|
updateStatus()
|
||||||
}
|
}
|
||||||
@@ -1107,7 +1153,13 @@ async function loadFixture(nextFixture: FixtureName) {
|
|||||||
const request = ++textureRequest
|
const request = ++textureRequest
|
||||||
loadingElement.hidden = false
|
loadingElement.hidden = false
|
||||||
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
|
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 {
|
try {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
@@ -1119,6 +1171,7 @@ async function loadFixture(nextFixture: FixtureName) {
|
|||||||
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
|
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
|
||||||
throw artworkResult.reason
|
throw artworkResult.reason
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maskResult.status === 'rejected') {
|
if (maskResult.status === 'rejected') {
|
||||||
artworkResult.value.dispose()
|
artworkResult.value.dispose()
|
||||||
throw maskResult.reason
|
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') {
|
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
|
||||||
const request = ++textureRequest
|
const request = ++textureRequest
|
||||||
const objectUrl = URL.createObjectURL(file)
|
const objectUrl = URL.createObjectURL(file)
|
||||||
@@ -1331,17 +1423,46 @@ packPrimaryButton.addEventListener('click', () => {
|
|||||||
if (pack) pack.primary()
|
if (pack) pack.primary()
|
||||||
else void preparePack()
|
else void preparePack()
|
||||||
})
|
})
|
||||||
packRestartButton.addEventListener('click', () => pack?.restart())
|
packRestartButton.addEventListener('click', () => { void preparePack(true) })
|
||||||
packSkipButton.addEventListener('click', () => pack?.skip())
|
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', () => {
|
modeSelect.addEventListener('change', () => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
updateMode()
|
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()
|
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', () => {
|
substrateSelect.addEventListener('change', () => {
|
||||||
pauseScriptedMotion()
|
pauseScriptedMotion()
|
||||||
controls.substrate = substrateSelect.value as SubstrateName
|
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 * as THREE from 'three'
|
||||||
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
|
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
|
||||||
import { createFoilWrapper } from './foilWrapper'
|
import { createFoilWrapper } from './foilWrapper'
|
||||||
|
import type { PackStyle } from './packDesigns'
|
||||||
import {
|
import {
|
||||||
applyEdgeMaterialControls,
|
applyEdgeMaterialControls,
|
||||||
applyMaterialControls,
|
applyMaterialControls,
|
||||||
@@ -9,19 +10,38 @@ import {
|
|||||||
type SubstrateName,
|
type SubstrateName,
|
||||||
} from './cardMaterial'
|
} from './cardMaterial'
|
||||||
|
|
||||||
export type PackFixture = 'David' | 'Timothy'
|
|
||||||
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
|
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
|
||||||
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
|
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
|
||||||
|
|
||||||
export const packContents: readonly {
|
export interface PackCardSpec {
|
||||||
fixture: PackFixture
|
fixture: string
|
||||||
finish: FinishName
|
finish: FinishName
|
||||||
substrate: SubstrateName
|
substrate: SubstrateName
|
||||||
}[] = [
|
}
|
||||||
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
|
|
||||||
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
|
export const packSize = 10
|
||||||
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
|
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.
|
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
|
||||||
const stackDepthSpacing = 0.055
|
const stackDepthSpacing = 0.055
|
||||||
@@ -34,7 +54,8 @@ const inspectionCameraRetreat = inspectionDepth - 0.85
|
|||||||
|
|
||||||
interface PackOptions {
|
interface PackOptions {
|
||||||
canvas: HTMLCanvasElement
|
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
|
backMaterial: THREE.Material
|
||||||
normalMap: THREE.Texture
|
normalMap: THREE.Texture
|
||||||
environment: THREE.CubeTexture
|
environment: THREE.CubeTexture
|
||||||
@@ -94,6 +115,7 @@ export class PackOpening {
|
|||||||
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
||||||
state: PackState = 'sealed'
|
state: PackState = 'sealed'
|
||||||
paused = false
|
paused = false
|
||||||
|
readonly contents: readonly PackCardSpec[]
|
||||||
private readonly options: PackOptions
|
private readonly options: PackOptions
|
||||||
private readonly wrapper = createFoilWrapper()
|
private readonly wrapper = createFoilWrapper()
|
||||||
private readonly cards
|
private readonly cards
|
||||||
@@ -119,13 +141,16 @@ export class PackOpening {
|
|||||||
private pinchDistance: number | undefined
|
private pinchDistance: number | undefined
|
||||||
|
|
||||||
constructor(options: PackOptions) {
|
constructor(options: PackOptions) {
|
||||||
|
if (!options.contents.length) throw new Error('Pack must contain at least one card')
|
||||||
this.options = options
|
this.options = options
|
||||||
|
this.contents = options.contents.map(spec => ({ ...spec }))
|
||||||
this.root.name = 'PACK_PROTOTYPE'
|
this.root.name = 'PACK_PROTOTYPE'
|
||||||
this.root.position.y = 0.22
|
this.root.position.y = 0.22
|
||||||
this.root.visible = false
|
this.root.visible = false
|
||||||
this.root.add(this.wrapper.root)
|
this.root.add(this.wrapper.root)
|
||||||
this.cards = packContents.map((spec) => {
|
this.cards = this.contents.map((spec) => {
|
||||||
const textures = options.textures[spec.fixture]
|
const textures = options.textures.get(spec.fixture)
|
||||||
|
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`)
|
||||||
const material = createCardMaterial(
|
const material = createCardMaterial(
|
||||||
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
|
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
|
||||||
)
|
)
|
||||||
@@ -144,16 +169,16 @@ export class PackOpening {
|
|||||||
|
|
||||||
get view() {
|
get view() {
|
||||||
const number = `${this.index + 1} / ${this.cards.length}`
|
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 }> = {
|
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: {
|
opening: {
|
||||||
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
|
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
|
||||||
status: this.openingProgress < 0.38
|
status: this.openingProgress < 0.38
|
||||||
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
|
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
|
||||||
: this.openingProgress < 0.52 ? 'Strip curling away'
|
: this.openingProgress < 0.52 ? 'Strip curling away'
|
||||||
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
|
: 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
|
hint: this.openingProgress < 0.38
|
||||||
? 'Pull right along the seam · release to hold · Finish opening resumes'
|
? 'Pull right along the seam · release to hold · Finish opening resumes'
|
||||||
: 'Touch or wheel pauses motion · Skip finishes this step',
|
: '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',
|
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' },
|
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
|
return this.paused && this.motion
|
||||||
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
|
? { ...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) {
|
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
|
||||||
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
|
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)
|
if (scene.environment) textures.add(scene.environment)
|
||||||
this.root.traverse((object) => {
|
this.root.traverse((object) => {
|
||||||
if (!(object instanceof THREE.Mesh)) return
|
if (!(object instanceof THREE.Mesh)) return
|
||||||
@@ -209,7 +241,7 @@ export class PackOpening {
|
|||||||
await renderer.compileAsync(this.root, this.camera, scene)
|
await renderer.compileAsync(this.root, this.camera, scene)
|
||||||
try {
|
try {
|
||||||
this.cards.forEach((card, index) => {
|
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)
|
await renderer.compileAsync(this.root, this.camera, scene)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -311,8 +343,9 @@ export class PackOpening {
|
|||||||
this.updateCamera()
|
this.updateCamera()
|
||||||
this.cards.forEach((card, index) => {
|
this.cards.forEach((card, index) => {
|
||||||
const target = this.stackPose(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.
|
// 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)
|
setPose(card.object, target)
|
||||||
})
|
})
|
||||||
const aside = smooth(value, 0.83, 0.87)
|
const aside = smooth(value, 0.83, 0.87)
|
||||||
@@ -340,9 +373,9 @@ export class PackOpening {
|
|||||||
})
|
})
|
||||||
if (state === 'inspecting' && !this.revealed.has(this.index)) {
|
if (state === 'inspecting' && !this.revealed.has(this.index)) {
|
||||||
this.revealed.add(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', {
|
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) {
|
if (state === 'complete' && !this.completed) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
select {
|
select,
|
||||||
|
input {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -108,6 +109,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select,
|
select,
|
||||||
|
input,
|
||||||
button {
|
button {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border: 1px solid rgba(227, 215, 181, 0.2);
|
border: 1px solid rgba(227, 215, 181, 0.2);
|
||||||
@@ -116,7 +118,8 @@ button {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select,
|
||||||
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 28px 0 10px;
|
padding: 0 28px 0 10px;
|
||||||
@@ -128,17 +131,39 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
select:hover {
|
select:hover,
|
||||||
|
input:hover {
|
||||||
border-color: rgba(218, 186, 102, 0.62);
|
border-color: rgba(218, 186, 102, 0.62);
|
||||||
background: #202630;
|
background: #202630;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
outline: 2px solid #d6b860;
|
outline: 2px solid #d6b860;
|
||||||
outline-offset: 2px;
|
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 {
|
.viewport-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -306,12 +331,18 @@ button:disabled {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-mode .toolbar > :not(:first-child) {
|
.toolbar .pack-style,
|
||||||
|
.pack-mode .toolbar > :not(:first-child):not(.pack-style) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pack-mode .toolbar .pack-style {
|
||||||
|
display: grid;
|
||||||
|
flex-basis: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.pack-mode .toolbar {
|
.pack-mode .toolbar {
|
||||||
flex: 0 1 180px;
|
flex: 0 1 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pack-mode .hint {
|
.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()],
|
||||||
|
})
|
||||||