Updated perf

This commit is contained in:
2026-09-09 18:01:25 -07:00
parent ad41f0e140
commit 2cfa0ca690
11 changed files with 485 additions and 46 deletions

View File

@@ -28,6 +28,38 @@ The functional spec already establishes:
- Duration or self-reported difficulty should not increase rewards.
- The economy should assume honest participation rather than attempt anti-cheat verification.
#### ANSWER:
Daily habits
Unlimited habits can be tracked.
Up to 5 can be reward-eligible.
Opening/checking in gives 2 cards.
Each completed reward-eligible habit adds 2 cards.
Maximum daily pack: 12 cards.
Changing which habits are reward-eligible should take effect the next day.
Weekly habits
Tracked separately from daily habits.
Can include things like church attendance, Bible study, volunteering, etc.
A limited number can contribute to a separate weekly pack.
This gives weekly commitments real significance without distorting the daily pack economy.
Users can track any number of weekly habits.
Up to 3 weekly habits can be reward-eligible.
Each completed reward-eligible weekly habit adds 2 cards.
Maximum weekly pack: 6 cards.
No separate “weekly login” cards.
Monthly habits
Supported as a tracking cadence.
No card reward for now.
They can still contribute to history/consistency metrics.
Reward design can be revisited later if monthly goals become important enough to justify it.
Daily maximum: 12 cards/day
Weekly maximum: 6 cards/week
Maximum weekly output from habits: 90 cards/week if someone gets every possible reward.
---
### 2. What is the lifecycle of a daily pack?
@@ -46,6 +78,18 @@ Questions to resolve:
This should become one explicit state machine in the functional spec.
#### ANSWER:
* Each day starts with a 2-card pack.
* Completing one of the day's five reward-eligible habits adds 2 cards, up to 12.
* The pack can be opened at any point.
* Opening permanently finalizes that day's reward state.
* Any reward-eligible habits completed or logged afterward still count toward habit history and consistency, but cannot add cards to that day's pack.
* Because users can log the previous day's habits, an unopened previous-day pack can still be upgraded during that grace period.
* Opening an unfinished pack triggers an explicit warning that doing so is irreversible and forfeits any remaining reward potential for that day.
* The warning applies equally when opening today's unfinished pack or an unfinished pack from the previous day.
* Users can disable that warning in settings.
---
### 3. How does habit completion upgrade a pack?
@@ -67,6 +111,10 @@ Questions to resolve:
- Should later upgrades affect every card in the pack or only newly-added slots?
- Is the pack upgrade path deterministic or probabilistic?
#### ANSWER:
Each completed reward-eligible habit adds 2 cards. Habit completion does not modify rarity odds, condition, material, finish, or pack type.
---
### 4. How much better is a fully completed day than simply logging in?
@@ -86,6 +134,11 @@ Questions to resolve:
- Full completion?
- Should full completion unlock a distinct final slot or presentation rather than merely adding more probability?
#### ANSWER:
For initial release, 2 cards for login vs. 12 cards for completing all five reward-eligible daily habits
We can modify as we see fit but that feels good currently.
---
### 5. What does consistency do mechanically?
@@ -115,6 +168,12 @@ Questions to resolve:
- Special non-economic recognition?
- How should consistency treat scheduled days versus days on which a habit was not expected?
#### ANSWER:
Positive-habit consistency is measured using completion rate over scheduled opportunities rather than streaks. Consistency is informational and does not modify card rewards. The application may recognize consistency milestones and returning after time away through non-economic messaging or visual acknowledgement.
7 / 30 / 90 / lifetime <- the windows we use for tracking consistency
---
### 6. How do weekly and other non-daily habits work?
@@ -136,6 +195,10 @@ Questions to resolve:
- How is consistency calculated for habits that are not expected every day?
- How do flexible goals such as "volunteer once this month" work?
#### ANSWER:
A weekly habit may require one or more completions within the week, but it occupies one weekly reward slot and awards its 2 cards only once, when its weekly target is met.
---
### 7. Do negative habits affect card rewards at all?

BIN
spikes/card-back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -67,6 +67,13 @@ unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, no
dedicated artwork faces, preventing depth-fighting bands at farther zoom distances.
Pack assets are loaded separately on first entry, with retry on failure.
Wrapper deformation is coalesced to once per rendered frame, including touch dents
and tear input. Fixed crease/crimp calculations are cached; unchanged pouch surfaces
and ribbons do not recalculate normals/bounds or upload vertex buffers. Hidden
wrappers do not deform. Pack UI updates are also coalesced and only write changed
values. These optimizations retain the same mesh resolution, deformation formulas,
materials, lighting, pixel ratio, and antialiasing.
There is no sound, haptics, particles, cloth simulation, backend, rewards persistence,
pack progress persistence, or timeline/editor. This is a choreography proof, not an
final pack-design or mobile performance sign-off. The approved rigid prototype and
@@ -116,6 +123,17 @@ Production build:
npm run build
```
Pack regression checks (Node 22.15+):
```sh
npm test
```
These check exact pre-optimization wrapper geometry snapshots, surface update
counts, per-frame input batching, pause/resume, pinch handoff, restart, reduced
motion, and ordered reveals. They are CPU/state checks, not a real-device GPU or
touch-latency benchmark; mobile performance still needs on-device validation.
The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline.
## Baseline

View File

@@ -6,6 +6,7 @@
"scripts": {
"assets": "node scripts/generate-reference-assets.mjs",
"export:blender": "node scripts/export-card-mesh.mjs",
"test": "node --test scripts/*.test.mjs",
"predev": "npm run assets",
"dev": "vite --host 0.0.0.0",
"prebuild": "npm run assets",

View File

@@ -0,0 +1,233 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import * as THREE from 'three'
import { installDOM, TestCanvas, geometryHash, wrapperCases } from './test-support.mjs'
const { createFoilWrapper } = await import('../src/foilWrapper.ts')
const { PackOpening } = await import('../src/packOpening.ts')
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
const originalHashes = [
'f20984ff506a2b6ac557e30592b4d3b990efdd5e9f335a2655fe78567e240d1a',
'2ae0cf1d2ba9fcc29dde5cd6c5cca0862e22466ed5a8c4257a3d0d0075b9d500',
'f8c912718e192139b6d33843c2c5527e765372f339897033c864909929b59bbe',
'1f70db18f24f8a629a0765e21cb1d73f377ffb816e2e7893edaabe9616053441',
'64e019c59fa6fe120ac044a01b2ba852ce09e86cd4abe6d286bb18d456bd8700',
'140e2ad4fdc339d671a8b19da32c89b74c98e785b05250f4a4c491edf23a460a',
'cacbbd787ff9f47acddf0227d31fe8051adf06d6e401a0f53a69a7d81c3c12fc',
'7a76c0eeea7cf22a3f6d888c43df76a2dcdeaad418d91c9cee5e5102a09ebf49',
'e5625218a4d89f58bb8a935d30fa6aee80613b41868bad334079fca6da33e397',
'f20984ff506a2b6ac557e30592b4d3b990efdd5e9f335a2655fe78567e240d1a',
'a6f746d8d729cf77068a05a24269024cd30c1a807828b907c559c140645a3b86',
'91fd70b90a7a40976ccfe9ad6727af5f12db6e8f20ac414503f5fe10aaee4d8b',
]
test('wrapper geometry exactly matches the original across tear, dent, mouth and restart poses', () => {
installDOM()
const wrapper = createFoilWrapper()
wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => {
wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit)
assert.equal(geometryHash(wrapper), originalHashes[index], `pose ${index}`)
})
})
test('only changed surfaces recompute normals, bounds and upload buffers', (t) => {
installDOM()
const wrapper = createFoilWrapper()
const meshes = wrapper.root.children
const normalSpies = meshes.map((mesh) => t.mock.method(mesh.geometry, 'computeVertexNormals'))
const boundsSpies = meshes.map((mesh) => t.mock.method(mesh.geometry, 'computeBoundingSphere'))
const versions = () => meshes.map((mesh) => [
mesh.geometry.getAttribute('position').version, mesh.geometry.getAttribute('normal').version,
])
const before = versions()
const touch = new THREE.Vector3()
wrapper.deform(0, 0, 0, touch)
assert.deepEqual(versions(), before)
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [0, 0, 0])
wrapper.deform(0.5, 0, 0, touch)
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [0, 0, 1])
assert.deepEqual(versions().slice(0, 2), before.slice(0, 2))
wrapper.deform(0.5, 0, 0, touch.set(0.4, 1.7, 0.5))
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [1, 0, 1])
wrapper.deform(0.5, 0, 0.5, touch)
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
})
function setupPack(t, reducedMotion = false) {
installDOM(reducedMotion)
let now = 1000
t.mock.method(performance, 'now', () => now)
const canvas = new TestCanvas()
const pack = new PackOpening({
canvas,
textures: {
David: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
Timothy: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
},
backMaterial: new THREE.MeshPhysicalMaterial(),
normalMap: new THREE.Texture(),
environment: new THREE.CubeTexture(),
lightPosition: new THREE.Vector3(2.2, 2.5, 4.4),
onChange() {},
})
pack.resize(canvas.width, canvas.height)
pack.setActive(true)
pack.tick(now)
const deform = t.mock.method(pack.wrapper, 'deform')
return {
pack, canvas, deform,
step(ms = 16) { now += ms; pack.tick(now) },
}
}
function pointer(id, x, y) {
return { pointerId: id, clientX: x, clientY: y, button: 0 }
}
test('a burst of tear events and the touch dent deform at most once per rendered frame', (t) => {
const { pack, deform, step } = setupPack(t)
const seam = pack.seamScreenBounds()
const start = pointer(1, seam.left + 10, seam.y)
pack.pointerDown(start)
for (let offset = 10; offset <= 100; offset += 10) {
pack.pointerMove(pointer(1, start.clientX + offset, start.clientY))
}
const progress = pack.tearProgress
assert.ok(progress > 0 && progress < 1)
assert.equal(deform.mock.callCount(), 0)
step()
assert.equal(deform.mock.callCount(), 1)
assert.equal(deform.mock.calls[0].arguments[0], progress)
assert.ok(deform.mock.calls[0].arguments[3].z > 0)
pack.pointerMove(pointer(1, start.clientX + 20, start.clientY))
assert.equal(pack.tearProgress, progress, 'pulling backward must not reseal')
pack.pointerUp(pointer(1, start.clientX + 20, start.clientY), true)
step()
assert.equal(pack.tearProgress, progress)
assert.equal(pack.paused, true)
pack.primary()
step(3200)
assert.equal(pack.state, 'stackReady')
})
test('body dragging batches dent changes without updating the back or ribbon', (t) => {
const { pack, deform, step } = setupPack(t)
pack.pointerDown(pointer(1, 195, 368))
for (let x = 200; x <= 220; x += 5) pack.pointerMove(pointer(1, x, 368))
assert.equal(deform.mock.callCount(), 0)
const [front, back, strip] = pack.wrapper.root.children
const backVersion = back.geometry.getAttribute('position').version
const stripVersion = strip.geometry.getAttribute('position').version
const frontVersion = front.geometry.getAttribute('position').version
step()
assert.equal(deform.mock.callCount(), 1)
assert.ok(front.geometry.getAttribute('position').version > frontVersion)
assert.equal(back.geometry.getAttribute('position').version, backVersion)
assert.equal(strip.geometry.getAttribute('position').version, stripVersion)
})
test('interruption and mode changes preserve progress without deforming in pointer handlers', (t) => {
const { pack, deform, step } = setupPack(t)
pack.primary()
step(600)
const calls = deform.mock.callCount()
const versions = pack.wrapper.root.children.map((mesh) => mesh.geometry.getAttribute('position').version)
pack.pointerDown(pointer(1, 0, 0))
const progress = pack.tearProgress
assert.equal(deform.mock.callCount(), calls)
assert.equal(pack.paused, true)
pack.pointerUp(pointer(1, 0, 0))
step(600)
assert.equal(pack.tearProgress, progress)
assert.deepEqual(
pack.wrapper.root.children.map((mesh) => mesh.geometry.getAttribute('position').version), versions,
)
const pausedCalls = deform.mock.callCount()
step()
assert.equal(deform.mock.callCount(), pausedCalls)
pack.setActive(false)
step(500)
pack.setActive(true)
assert.equal(pack.tearProgress, progress)
assert.equal(pack.view.action, 'Continue')
pack.primary()
step(3200)
assert.equal(pack.state, 'stackReady')
})
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
const { pack, canvas, step } = setupPack(t)
const seam = pack.seamScreenBounds()
const start = pointer(1, seam.left + 10, seam.y)
pack.pointerDown(start)
pack.pointerMove(pointer(1, start.clientX + 70, seam.y))
step()
const progress = pack.tearProgress
pack.pointerDown(pointer(2, start.clientX + 170, seam.y))
pack.pointerMove(pointer(2, start.clientX + 200, seam.y))
assert.equal(pack.tearProgress, progress)
assert.ok(pack.zoom < 1)
pack.restart()
step()
assert.equal(canvas.captures.size, 0)
assert.equal(pack.tearProgress, 0)
assert.equal(geometryHash(pack.wrapper), originalHashes[0])
})
test('full-pull release continues opening and hidden wrappers do no geometry work', (t) => {
const { pack, deform, step } = setupPack(t)
const seam = pack.seamScreenBounds()
pack.pointerDown(pointer(1, seam.left + 10, seam.y))
pack.pointerMove(pointer(1, seam.right + 10, seam.y))
pack.pointerUp(pointer(1, seam.right + 10, seam.y))
assert.equal(pack.tearProgress, 1)
step(3200)
assert.equal(pack.state, 'stackReady')
assert.equal(pack.wrapper.root.visible, false)
assert.equal(deform.mock.callCount(), 0)
pack.restart()
step()
assert.equal(pack.wrapper.root.visible, true)
assert.equal(geometryHash(pack.wrapper), originalHashes[0])
})
test('skip retains ordered lift/reveal states and emits each reveal and completion once', (t) => {
const { pack, canvas, step } = setupPack(t)
const reveals = []
let completions = 0
canvas.addEventListener('packreveal', (event) => reveals.push(event.detail.index))
canvas.addEventListener('packcomplete', () => completions++)
pack.skip()
step()
for (let index = 1; index <= 3; index++) {
assert.equal(pack.state, 'stackReady')
assert.equal(pack.cards.some((card) => card.front.visible), false)
pack.skip()
assert.equal(pack.state, 'lifted')
assert.equal(pack.cards.some((card) => card.front.visible), false)
pack.skip()
assert.equal(pack.state, 'inspecting')
step()
pack.skip()
}
assert.equal(pack.state, 'complete')
pack.skip()
assert.deepEqual(reveals, [1, 2, 3])
assert.equal(completions, 1)
pack.restart()
step()
assert.equal(pack.state, 'sealed')
assert.equal(geometryHash(pack.wrapper), originalHashes[0])
})
test('reduced-motion opening keeps the existing 70 ms transition duration', (t) => {
const { pack, step } = setupPack(t, true)
pack.primary()
step(69)
assert.equal(pack.state, 'opening')
step(1)
assert.equal(pack.state, 'stackReady')
})

View File

@@ -0,0 +1,74 @@
import { registerHooks } from 'node:module'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { createHash } from 'node:crypto'
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('./') && context.parentURL?.endsWith('.ts') && !specifier.endsWith('.ts')) {
return nextResolve(`${specifier}.ts`, context)
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (!url.endsWith('.ts')) return nextLoad(url, context)
const source = ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext },
}).outputText
return { format: 'module', source, shortCircuit: true }
},
})
export class TestCanvas extends EventTarget {
width = 390
height = 736
captures = new Set()
getContext() {
return {
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
}
}
getBoundingClientRect() { return { left: 0, top: 0, width: this.width, height: this.height } }
setPointerCapture(id) { this.captures.add(id) }
hasPointerCapture(id) { return this.captures.has(id) }
releasePointerCapture(id) { this.captures.delete(id) }
}
export function installDOM(reducedMotion = false) {
globalThis.document = {
createElement(name) {
if (name !== 'canvas') throw new Error(`Unexpected test element: ${name}`)
return new TestCanvas()
},
}
globalThis.window = { matchMedia: () => ({ matches: reducedMotion }) }
}
export function geometryHash(wrapper) {
const hash = createHash('sha256')
for (const mesh of wrapper.root.children) {
for (const name of ['position', 'normal', 'uv']) {
const array = mesh.geometry.getAttribute(name).array
hash.update(Buffer.from(array.buffer, array.byteOffset, array.byteLength))
}
hash.update(JSON.stringify(mesh.geometry.boundingSphere))
hash.update(String(mesh.visible))
}
return hash.digest('hex')
}
export const wrapperCases = [
[0, 0, 0, [0, 0, 0], 4.8],
[0.25, 0, 0, [-1.2, 1.7, 0.6], 4.8],
[0.89, 0, 0, [0.2, 1.7, 1], 4.8],
[1, 0, 0, [1.6, 1.7, 0.8], 4.8],
[1, 0.4, 0, [1.6, 1.7, 0.3], 6.3],
[1, 0.99, 0.05, [1.6, 1.7, 0.1], 6.3],
[1, 1, 0.5, [0, 0, 0], 6.3],
[1, 1, 1, [0, 0, 0], 6.3],
[1, 1, 1, [0, 0, 0], 12.7],
[0, 0, 0, [0, 0, 0], 4.8],
[0, 0, 0, [0.3, -1.2, 1], 4.8],
[0, 0, 0, [-1.68, -2.24, 0.001], 4.8],
]

View File

@@ -89,52 +89,83 @@ export function createFoilWrapper() {
return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173)
}
const sheetUV = sheets[0].mesh.geometry.getAttribute('uv')
const sheetSamples = Array.from({ length: sheetUV.count }, (_, i) => {
const u = sheetUV.getX(i)
const y = bottom + sheetUV.getY(i) * (top - bottom)
const x = (u * 2 - 1) * halfWidth
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
const lower = smooth(y, bottom + 0.14, bottom + 0.47)
const neck = 1 - smooth(y, 1.65, seam)
const envelope = across * lower
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5))
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
return { x, y, across, neck, envelope, folds, crimp,
opening: smooth(y, 0.6, seam), lip: smooth(y, 1, seam),
edgeY: y + tornEdge(u) * smooth(y, 1.85, seam) }
})
const stripSamples = Array.from({ length: stripUV.count }, (_, i) => {
const u = stripUV.getX(i)
const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam)
return { u,
y: seam + v * (top - seam) + tornEdge(u) * (1 - v),
z: midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI) }
})
for (const geometry of [...sheets.map(({ mesh }) => mesh.geometry), stripGeometry]) {
for (const name of ['position', 'normal']) {
const attribute = geometry.getAttribute(name)
const buffer = attribute instanceof THREE.InterleavedBufferAttribute ? attribute.data : attribute
buffer.setUsage(THREE.DynamicDrawUsage)
}
}
let lastMouth: number | undefined
let lastTear: number | undefined
let lastDetach: number | undefined
let lastStripExit: number | undefined
const lastTouch = new THREE.Vector3()
function deform(tear: number, detach: number, mouth: number, touch: THREE.Vector3, stripExit = 4.8) {
for (const { mesh, side } of sheets) {
if (mouth === lastMouth && (side === -1 || touch.equals(lastTouch))) continue
const geometry = mesh.geometry
const positions = geometry.getAttribute('position')
const uv = geometry.getAttribute('uv')
for (let i = 0; i < positions.count; i++) {
const u = uv.getX(i)
const y = bottom + uv.getY(i) * (top - bottom)
const x = (u * 2 - 1) * halfWidth
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
const lower = smooth(y, bottom + 0.14, bottom + 0.47)
const neck = 1 - smooth(y, 1.65, seam)
const opening = mouth * smooth(y, 0.6, seam)
const envelope = across * lower
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5))
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
const sample = sheetSamples[i]
const { x, y, across, neck, envelope, folds, crimp } = sample
const opening = mouth * sample.opening
const dent = side === 1 ? -0.045 * touch.z
* Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0
const depth = 0.37 * neck + 0.64 * opening
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * smooth(y, 1, seam)
positions.setXYZ(i, x, y + tornEdge(u) * smooth(y, 1.85, seam) + lip,
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * sample.lip
positions.setXYZ(i, x, sample.edgeY + lip,
midDepth + side * (depth * envelope + folds * neck + crimp) + dent)
}
positions.needsUpdate = true
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
}
lastMouth = mouth
lastTouch.copy(touch)
if (tear === lastTear && detach === lastDetach && (detach === 0 || stripExit === lastStripExit)) return
lastTear = tear
lastDetach = detach
lastStripExit = stripExit
const positions = stripGeometry.getAttribute('position')
const curvature = 0.55 + detach * 0.5
for (let i = 0; i < positions.count; i++) {
const u = stripUV.getX(i)
const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam)
const { u, y, z } = stripSamples[i]
const length = Math.max(0, tear - u) * halfWidth * 2
const curvature = 0.55 + detach * 0.5
const angle = length * curvature
const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth
const release = smooth(length, 0, 0.3)
// The released end rolls up around the moving tear front; the attached end stays sealed.
positions.setXYZ(i,
anchor - Math.sin(angle) / curvature + detach * stripExit,
seam + v * (top - seam) + tornEdge(u) * (1 - v)
+ 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI)
- 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
y + 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
z - 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
}
positions.needsUpdate = true
stripGeometry.computeVertexNormals()

View File

@@ -565,6 +565,7 @@ let lastPerformanceUpdate = performance.now()
let lastFrameTime: number | undefined
let pack: PackOpening | undefined
let packLoading = false
let packUIDirty = false
let packError: string | undefined
function updateMaterial() {
@@ -684,21 +685,28 @@ function updateStatus() {
}
function updatePackUI() {
packUIDirty = false
if (mode !== 'Pack') return
const view = pack?.view
packControlsElement.dataset.state = pack?.state ?? (packError ? 'error' : 'loading')
packControlsElement.dataset.paused = String(pack?.paused ?? false)
packTearProgress.hidden = !!pack && pack.state !== 'sealed' && pack.state !== 'opening'
packTearProgress.value = pack?.tearProgress ?? 0
packPrimaryButton.textContent = view?.action ?? (packError ? 'Retry loading' : 'Loading…')
packPrimaryButton.disabled = packLoading || pack?.state === 'complete'
packRestartButton.disabled = !pack
packSkipButton.disabled = !pack || pack.state === 'complete'
packSkipButton.title = 'Finish this motion or move to the next resting state'
const state = pack?.state ?? (packError ? 'error' : 'loading')
const paused = String(pack?.paused ?? false)
if (packControlsElement.dataset.state !== state) packControlsElement.dataset.state = state
if (packControlsElement.dataset.paused !== paused) packControlsElement.dataset.paused = paused
setIfChanged(packTearProgress, 'hidden', !!pack && pack.state !== 'sealed' && pack.state !== 'opening')
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
setIfChanged(packRestartButton, 'disabled', !pack)
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
if (packStatusElement.textContent !== status) packStatusElement.textContent = status
statusElement.textContent = 'Foil tear proof · 3 authored cards · no random rewards'
hintElement.textContent = view?.hint ?? 'Local reference artwork · approved runtime card materials'
setIfChanged(packStatusElement, 'textContent', status)
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
}
function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
if (target[key] !== value) target[key] = value
}
async function preparePack() {
@@ -739,7 +747,7 @@ async function preparePack() {
normalMap,
environment: activeEnvironment,
lightPosition,
onChange: updatePackUI,
onChange: () => { packUIDirty = true },
})
scene.add(pack.root)
pack.syncLighting(frontMaterial)
@@ -1462,6 +1470,7 @@ function animate(frameTime: number) {
}
if (mode === 'Pack') pack?.tick(frameTime)
if (packUIDirty) updatePackUI()
renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera)
}

View File

@@ -112,6 +112,8 @@ export class PackOpening {
private readonly touch = new THREE.Vector3()
private touchTarget = 0
private lastTick: number | undefined
private wrapperDirty = false
private wrapperStripExit = 4.8
private readonly pointers = new Map<number, THREE.Vector2>()
private tap: { id: number; x: number; y: number; at: number } | undefined
private pinchDistance: number | undefined
@@ -234,7 +236,7 @@ export class PackOpening {
this.updateCamera()
this.wrapper.root.visible = true
setPose(this.wrapper.root, pose(0, 0, 0))
this.wrapper.deform(0, 0, 0, this.touch)
this.invalidateWrapper()
this.cards.forEach((card, index) => {
card.object.visible = true
card.front.visible = false
@@ -244,17 +246,15 @@ export class PackOpening {
this.setState('sealed')
}
private shapeWrapper() {
const stripExit = (this.camera.position.z + 4)
private invalidateWrapper() {
this.wrapperDirty = true
this.wrapperStripExit = (this.camera.position.z + 4)
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2
this.wrapper.deform(this.tearProgress,
THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, stripExit)
}
private setOpeningProgress(value: number) {
this.openingProgress = value
this.shapeWrapper()
this.invalidateWrapper()
const smooth = THREE.MathUtils.smoothstep
const settling = smooth(value, 0.87, 1)
const framing = smooth(value, 0.52, 0.76) * (1 - settling)
@@ -397,13 +397,23 @@ export class PackOpening {
}
tick(now: number) {
this.updateMotion(now)
if (this.active && this.wrapperDirty && this.wrapper.root.visible) {
this.wrapper.deform(this.tearProgress,
THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, this.wrapperStripExit)
this.wrapperDirty = false
}
}
private updateMotion(now: number) {
const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000))
this.lastTick = now
if (!this.active) return
if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
this.touch.z = this.reducedMotion.matches ? this.touchTarget
: THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
this.shapeWrapper()
this.invalidateWrapper()
}
const motion = this.motion
if (!motion || this.paused) return
@@ -423,7 +433,7 @@ export class PackOpening {
private pause(now: number) {
const interrupted = this.motion !== undefined
this.tick(now)
this.updateMotion(now)
if (this.motion && !this.paused) {
this.motion.elapsed += now - this.motion.startedAt
this.paused = true
@@ -493,7 +503,7 @@ export class PackOpening {
if (local) {
this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68)
this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7)
this.shapeWrapper()
this.invalidateWrapper()
}
}
}

BIN
spikes/common.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
spikes/legendary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB