Updated perf
This commit is contained in:
@@ -28,6 +28,38 @@ The functional spec already establishes:
|
|||||||
- Duration or self-reported difficulty should not increase rewards.
|
- Duration or self-reported difficulty should not increase rewards.
|
||||||
- The economy should assume honest participation rather than attempt anti-cheat verification.
|
- 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?
|
### 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.
|
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?
|
### 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?
|
- Should later upgrades affect every card in the pack or only newly-added slots?
|
||||||
- Is the pack upgrade path deterministic or probabilistic?
|
- 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?
|
### 4. How much better is a fully completed day than simply logging in?
|
||||||
@@ -86,6 +134,11 @@ Questions to resolve:
|
|||||||
- Full completion?
|
- Full completion?
|
||||||
- Should full completion unlock a distinct final slot or presentation rather than merely adding more probability?
|
- 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?
|
### 5. What does consistency do mechanically?
|
||||||
@@ -115,6 +168,12 @@ Questions to resolve:
|
|||||||
- Special non-economic recognition?
|
- Special non-economic recognition?
|
||||||
- How should consistency treat scheduled days versus days on which a habit was not expected?
|
- 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?
|
### 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 is consistency calculated for habits that are not expected every day?
|
||||||
- How do flexible goals such as "volunteer once this month" work?
|
- 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?
|
### 7. Do negative habits affect card rewards at all?
|
||||||
|
|||||||
BIN
spikes/card-back.png
Normal file
BIN
spikes/card-back.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -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.
|
dedicated artwork faces, preventing depth-fighting bands at farther zoom distances.
|
||||||
Pack assets are loaded separately on first entry, with retry on failure.
|
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,
|
There is no sound, haptics, particles, cloth simulation, backend, rewards persistence,
|
||||||
pack progress persistence, or timeline/editor. This is a choreography proof, not an
|
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
|
final pack-design or mobile performance sign-off. The approved rigid prototype and
|
||||||
@@ -116,6 +123,17 @@ Production build:
|
|||||||
npm run 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.
|
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
|
## Baseline
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"assets": "node scripts/generate-reference-assets.mjs",
|
"assets": "node scripts/generate-reference-assets.mjs",
|
||||||
"export:blender": "node scripts/export-card-mesh.mjs",
|
"export:blender": "node scripts/export-card-mesh.mjs",
|
||||||
|
"test": "node --test scripts/*.test.mjs",
|
||||||
"predev": "npm run assets",
|
"predev": "npm run assets",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"prebuild": "npm run assets",
|
"prebuild": "npm run assets",
|
||||||
|
|||||||
233
spikes/card-harness/scripts/pack-performance.test.mjs
Normal file
233
spikes/card-harness/scripts/pack-performance.test.mjs
Normal 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')
|
||||||
|
})
|
||||||
74
spikes/card-harness/scripts/test-support.mjs
Normal file
74
spikes/card-harness/scripts/test-support.mjs
Normal 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],
|
||||||
|
]
|
||||||
@@ -89,52 +89,83 @@ export function createFoilWrapper() {
|
|||||||
return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173)
|
return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deform(tear: number, detach: number, mouth: number, touch: THREE.Vector3, stripExit = 4.8) {
|
const sheetUV = sheets[0].mesh.geometry.getAttribute('uv')
|
||||||
for (const { mesh, side } of sheets) {
|
const sheetSamples = Array.from({ length: sheetUV.count }, (_, i) => {
|
||||||
const geometry = mesh.geometry
|
const u = sheetUV.getX(i)
|
||||||
const positions = geometry.getAttribute('position')
|
const y = bottom + sheetUV.getY(i) * (top - bottom)
|
||||||
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 x = (u * 2 - 1) * halfWidth
|
||||||
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
|
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 lower = smooth(y, bottom + 0.14, bottom + 0.47)
|
||||||
const neck = 1 - smooth(y, 1.65, seam)
|
const neck = 1 - smooth(y, 1.65, seam)
|
||||||
const opening = mouth * smooth(y, 0.6, seam)
|
|
||||||
const envelope = across * lower
|
const envelope = across * lower
|
||||||
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
|
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))
|
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))
|
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
|
||||||
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
|
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
|
||||||
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
|
* (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')
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
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
|
const dent = side === 1 ? -0.045 * touch.z
|
||||||
* Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0
|
* Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0
|
||||||
const depth = 0.37 * neck + 0.64 * opening
|
const depth = 0.37 * neck + 0.64 * opening
|
||||||
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * smooth(y, 1, seam)
|
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * sample.lip
|
||||||
positions.setXYZ(i, x, y + tornEdge(u) * smooth(y, 1.85, seam) + lip,
|
positions.setXYZ(i, x, sample.edgeY + lip,
|
||||||
midDepth + side * (depth * envelope + folds * neck + crimp) + dent)
|
midDepth + side * (depth * envelope + folds * neck + crimp) + dent)
|
||||||
}
|
}
|
||||||
positions.needsUpdate = true
|
positions.needsUpdate = true
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
geometry.computeBoundingSphere()
|
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 positions = stripGeometry.getAttribute('position')
|
||||||
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 length = Math.max(0, tear - u) * halfWidth * 2
|
|
||||||
const curvature = 0.55 + detach * 0.5
|
const curvature = 0.55 + detach * 0.5
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
const { u, y, z } = stripSamples[i]
|
||||||
|
const length = Math.max(0, tear - u) * halfWidth * 2
|
||||||
const angle = length * curvature
|
const angle = length * curvature
|
||||||
const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth
|
const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth
|
||||||
const release = smooth(length, 0, 0.3)
|
const release = smooth(length, 0, 0.3)
|
||||||
// The released end rolls up around the moving tear front; the attached end stays sealed.
|
// The released end rolls up around the moving tear front; the attached end stays sealed.
|
||||||
positions.setXYZ(i,
|
positions.setXYZ(i,
|
||||||
anchor - Math.sin(angle) / curvature + detach * stripExit,
|
anchor - Math.sin(angle) / curvature + detach * stripExit,
|
||||||
seam + v * (top - seam) + tornEdge(u) * (1 - v)
|
y + 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
|
||||||
+ 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
|
z - 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
positions.needsUpdate = true
|
positions.needsUpdate = true
|
||||||
stripGeometry.computeVertexNormals()
|
stripGeometry.computeVertexNormals()
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ let lastPerformanceUpdate = performance.now()
|
|||||||
let lastFrameTime: number | undefined
|
let lastFrameTime: number | undefined
|
||||||
let pack: PackOpening | undefined
|
let pack: PackOpening | undefined
|
||||||
let packLoading = false
|
let packLoading = false
|
||||||
|
let packUIDirty = false
|
||||||
let packError: string | undefined
|
let packError: string | undefined
|
||||||
|
|
||||||
function updateMaterial() {
|
function updateMaterial() {
|
||||||
@@ -684,21 +685,28 @@ function updateStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePackUI() {
|
function updatePackUI() {
|
||||||
|
packUIDirty = false
|
||||||
if (mode !== 'Pack') return
|
if (mode !== 'Pack') return
|
||||||
const view = pack?.view
|
const view = pack?.view
|
||||||
packControlsElement.dataset.state = pack?.state ?? (packError ? 'error' : 'loading')
|
const state = pack?.state ?? (packError ? 'error' : 'loading')
|
||||||
packControlsElement.dataset.paused = String(pack?.paused ?? false)
|
const paused = String(pack?.paused ?? false)
|
||||||
packTearProgress.hidden = !!pack && pack.state !== 'sealed' && pack.state !== 'opening'
|
if (packControlsElement.dataset.state !== state) packControlsElement.dataset.state = state
|
||||||
packTearProgress.value = pack?.tearProgress ?? 0
|
if (packControlsElement.dataset.paused !== paused) packControlsElement.dataset.paused = paused
|
||||||
packPrimaryButton.textContent = view?.action ?? (packError ? 'Retry loading' : 'Loading…')
|
setIfChanged(packTearProgress, 'hidden', !!pack && pack.state !== 'sealed' && pack.state !== 'opening')
|
||||||
packPrimaryButton.disabled = packLoading || pack?.state === 'complete'
|
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
||||||
packRestartButton.disabled = !pack
|
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
||||||
packSkipButton.disabled = !pack || pack.state === 'complete'
|
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
|
||||||
packSkipButton.title = 'Finish this motion or move to the next resting state'
|
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…'
|
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
||||||
if (packStatusElement.textContent !== status) packStatusElement.textContent = status
|
setIfChanged(packStatusElement, 'textContent', status)
|
||||||
statusElement.textContent = 'Foil tear proof · 3 authored cards · no random rewards'
|
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
|
||||||
hintElement.textContent = view?.hint ?? 'Local reference artwork · approved runtime card materials'
|
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() {
|
async function preparePack() {
|
||||||
@@ -739,7 +747,7 @@ async function preparePack() {
|
|||||||
normalMap,
|
normalMap,
|
||||||
environment: activeEnvironment,
|
environment: activeEnvironment,
|
||||||
lightPosition,
|
lightPosition,
|
||||||
onChange: updatePackUI,
|
onChange: () => { packUIDirty = true },
|
||||||
})
|
})
|
||||||
scene.add(pack.root)
|
scene.add(pack.root)
|
||||||
pack.syncLighting(frontMaterial)
|
pack.syncLighting(frontMaterial)
|
||||||
@@ -1462,6 +1470,7 @@ function animate(frameTime: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'Pack') pack?.tick(frameTime)
|
if (mode === 'Pack') pack?.tick(frameTime)
|
||||||
|
if (packUIDirty) updatePackUI()
|
||||||
renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera)
|
renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export class PackOpening {
|
|||||||
private readonly touch = new THREE.Vector3()
|
private readonly touch = new THREE.Vector3()
|
||||||
private touchTarget = 0
|
private touchTarget = 0
|
||||||
private lastTick: number | undefined
|
private lastTick: number | undefined
|
||||||
|
private wrapperDirty = false
|
||||||
|
private wrapperStripExit = 4.8
|
||||||
private readonly pointers = new Map<number, THREE.Vector2>()
|
private readonly pointers = new Map<number, THREE.Vector2>()
|
||||||
private tap: { id: number; x: number; y: number; at: number } | undefined
|
private tap: { id: number; x: number; y: number; at: number } | undefined
|
||||||
private pinchDistance: number | undefined
|
private pinchDistance: number | undefined
|
||||||
@@ -234,7 +236,7 @@ export class PackOpening {
|
|||||||
this.updateCamera()
|
this.updateCamera()
|
||||||
this.wrapper.root.visible = true
|
this.wrapper.root.visible = true
|
||||||
setPose(this.wrapper.root, pose(0, 0, 0))
|
setPose(this.wrapper.root, pose(0, 0, 0))
|
||||||
this.wrapper.deform(0, 0, 0, this.touch)
|
this.invalidateWrapper()
|
||||||
this.cards.forEach((card, index) => {
|
this.cards.forEach((card, index) => {
|
||||||
card.object.visible = true
|
card.object.visible = true
|
||||||
card.front.visible = false
|
card.front.visible = false
|
||||||
@@ -244,17 +246,15 @@ export class PackOpening {
|
|||||||
this.setState('sealed')
|
this.setState('sealed')
|
||||||
}
|
}
|
||||||
|
|
||||||
private shapeWrapper() {
|
private invalidateWrapper() {
|
||||||
const stripExit = (this.camera.position.z + 4)
|
this.wrapperDirty = true
|
||||||
|
this.wrapperStripExit = (this.camera.position.z + 4)
|
||||||
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2
|
* 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) {
|
private setOpeningProgress(value: number) {
|
||||||
this.openingProgress = value
|
this.openingProgress = value
|
||||||
this.shapeWrapper()
|
this.invalidateWrapper()
|
||||||
const smooth = THREE.MathUtils.smoothstep
|
const smooth = THREE.MathUtils.smoothstep
|
||||||
const settling = smooth(value, 0.87, 1)
|
const settling = smooth(value, 0.87, 1)
|
||||||
const framing = smooth(value, 0.52, 0.76) * (1 - settling)
|
const framing = smooth(value, 0.52, 0.76) * (1 - settling)
|
||||||
@@ -397,13 +397,23 @@ export class PackOpening {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tick(now: number) {
|
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))
|
const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000))
|
||||||
this.lastTick = now
|
this.lastTick = now
|
||||||
if (!this.active) return
|
if (!this.active) return
|
||||||
if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
|
if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
|
||||||
this.touch.z = this.reducedMotion.matches ? this.touchTarget
|
this.touch.z = this.reducedMotion.matches ? this.touchTarget
|
||||||
: THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
|
: THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
|
||||||
this.shapeWrapper()
|
this.invalidateWrapper()
|
||||||
}
|
}
|
||||||
const motion = this.motion
|
const motion = this.motion
|
||||||
if (!motion || this.paused) return
|
if (!motion || this.paused) return
|
||||||
@@ -423,7 +433,7 @@ export class PackOpening {
|
|||||||
|
|
||||||
private pause(now: number) {
|
private pause(now: number) {
|
||||||
const interrupted = this.motion !== undefined
|
const interrupted = this.motion !== undefined
|
||||||
this.tick(now)
|
this.updateMotion(now)
|
||||||
if (this.motion && !this.paused) {
|
if (this.motion && !this.paused) {
|
||||||
this.motion.elapsed += now - this.motion.startedAt
|
this.motion.elapsed += now - this.motion.startedAt
|
||||||
this.paused = true
|
this.paused = true
|
||||||
@@ -493,7 +503,7 @@ export class PackOpening {
|
|||||||
if (local) {
|
if (local) {
|
||||||
this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68)
|
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.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7)
|
||||||
this.shapeWrapper()
|
this.invalidateWrapper()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
spikes/common.png
Normal file
BIN
spikes/common.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
BIN
spikes/legendary.png
Normal file
BIN
spikes/legendary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
Reference in New Issue
Block a user