Animate cosmic dice without keeping physics running
This commit is contained in:
1 parent
320b8a9cb0
commit
a5e70e1f86
18 files changed
+664
-45
No files matched your search
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "21",
|
||||
"dependencies": {
|
||||
"@3d-dice/dice-box": "^1.1.4",
|
||||
"@3d-dice/dice-box": "1.1.4",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"expo": "~55.0.14",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Cosmic black-hole animation
|
||||
|
||||
The approved Blender motion study is integrated into the offline WebView.
|
||||
The public theme id, picker, player persistence and quality selection are unchanged.
|
||||
|
||||
| Quality | Appearance | Rendering after a roll |
|
||||
| --- | --- | --- |
|
||||
| Low | Approved static low model | No idle frames |
|
||||
| Medium | Two disk texture layers, one and two revolutions per 12 seconds | Up to 30 fps |
|
||||
| High | Opposing flows, distorted filaments, blue/magenta plasma and moving hot spots | Up to 30 fps |
|
||||
|
||||
Motion continues after both dice settle. Physics still stops normally and the
|
||||
roll completes once. The entire d6 geometry, pure-black central core, 21 stars,
|
||||
black outlines and constellation trails stay fixed relative to each die.
|
||||
The shader changes only disk color/UV sampling. Both dice instance the same
|
||||
quality model and material. All other themes retain their standard rendering.
|
||||
|
||||
## Files and implementation
|
||||
|
||||
- `assets/dice-theme-source/cosmicBlackHole/cosmic-motion-study.blend`: approved,
|
||||
editable 12-second Blender study, with packed images. The existing static
|
||||
authoring file and all generated mesh/texture files are unchanged.
|
||||
- `src/dice-box/cosmic-material.js`: Babylon CustomMaterial translation. Medium
|
||||
samples the existing diffuse atlas twice. High adds smooth procedural noise,
|
||||
distortion, spiral plasma and hot spots. The white atlas corner used by pips,
|
||||
outlines, trails and core is excluded from animation. Disk light is emitted
|
||||
independently of scene lights; the other parts retain their existing material.
|
||||
- `src/dice-box/cosmic-motion.js`: a separate idle animation loop. The original
|
||||
Dice-Box loop still stops its physics worker and emits completion. Only then
|
||||
does this loop redraw the scene. Flow speed uses elapsed time, with a bounded
|
||||
12-second phase, rather than a frame counter.
|
||||
- `scripts/patch-dice-box.mjs`: applies a small adapter to the installed 1.1.4
|
||||
bundle **in memory during esbuild**, not in node_modules. It hooks material
|
||||
creation and settled/rolling/clear lifecycle events, adds disposal and a
|
||||
read-only `getVisualState()` diagnostic method, and fixes resize listener
|
||||
accumulation. Resize requests wait until physics initialization is complete.
|
||||
- `src/html/dice-bridge.js`: waits for pending theme loading before disposing an
|
||||
old world during resets. It retains `THEME` and `updateConfig({ theme })`, and
|
||||
handles an additional `VISIBILITY` message for animation lifecycle only.
|
||||
- `Dice3DViewer.tsx` and `GameScreen.tsx`: forward AppState and screen focus.
|
||||
Animation pauses when the app is inactive, the game screen is covered by
|
||||
another route, or the WebView document is hidden. Resume does not jump through
|
||||
the time spent hidden. Resets remove idle frames, scene observers, resize
|
||||
listeners, the engine, message port and physics worker.
|
||||
- Tests cover shader isolation, static low/other themes, elapsed-time flow,
|
||||
frame limiting, hidden time, detachment, disposal, native visibility, resets,
|
||||
pending theme loads and unchanged roll results.
|
||||
|
||||
No new theme configuration fields, runtime downloads or CDN resources are used.
|
||||
The adapter reuses the Babylon classes already present in Dice-Box, avoiding a
|
||||
second Babylon runtime. `sync:dice` still copies third-party assets first, then
|
||||
repository-owned themes. Blender sources do not enter the Android asset bundle.
|
||||
The IIFE bundle is approximately 2.6 MB, as before (rounded).
|
||||
|
||||
## Maintained library boundary
|
||||
|
||||
Dice-Box is pinned to **1.1.4** in package.json and bun.lock. The adapter verifies
|
||||
SHA-256 hashes of the two installed input files and checks each replacement
|
||||
occurs exactly once. An upgrade fails the build until its changes are reviewed.
|
||||
The upstream source is [Dice-Box](https://github.com/3d-dice/dice-box); its MIT
|
||||
notice is preserved in `src/dice-box/LICENSE.txt`.
|
||||
|
||||
The adapter targets this application's existing `offscreen: false` WebView.
|
||||
It does not add animation support to Dice-Box's OffscreenCanvas worker mode.
|
||||
Game rules, random generation, collider, result map, `roll('2d6')`, extraction,
|
||||
scoring and turn handling are unchanged.
|
||||
|
||||
## Validation commands
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```powershell
|
||||
bun install --frozen-lockfile
|
||||
bun run sync:dice
|
||||
bun run lint
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run test:coverage
|
||||
bun run check:dice:selection
|
||||
$env:DICE_BROWSER_EXECUTABLE = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||
$env:DICE_QA_OUTPUT = 'C:\Users\Nessar\Documents\Codex\2026-09-07\work-directly-in-https-gitlab-nessar\outputs\animated-app'
|
||||
bun run check:dice:browser
|
||||
```
|
||||
|
||||
The browser check uses actual WASM physics and Babylon WebGL over local file
|
||||
URLs. It asserts two valid engine results, one completion, no idle physics
|
||||
commands, unchanged settled poses, changing medium/high pixels, identical low
|
||||
and existing-theme pixels, stopped physics render loops, pause/resume and
|
||||
disposed old quality worlds. It tests every existing theme and returns to all
|
||||
three qualities afterward. It checks all required theme files byte-for-byte
|
||||
against the Android bundle and rejects runtime HTTP requests or browser errors.
|
||||
|
||||
Validated on 2026-09-07: all commands above passed, with 72 tests, 97.38% line
|
||||
coverage and 94.13% function coverage. Lint reports four existing warnings and
|
||||
no errors. The browser run completed 16 real 2d6 rolls, observed all six face
|
||||
values, verified every existing theme and all three cosmic qualities, and
|
||||
reported no missing-file or browser errors. Selection/persistence smoke checks
|
||||
also passed. Logs, screenshots and structured browser results accompany the
|
||||
delivery in `outputs/animated-app` outside the repository.
|
||||
|
||||
## Limits
|
||||
|
||||
This reproduces the Blender effect in a mobile-oriented shader; high's smooth
|
||||
noise is an approximation of Blender's 4D Noise node, so individual eddies are
|
||||
not pixel-identical. No real gravitational lensing, bloom or moving geometry
|
||||
is introduced. Fine filaments blend together at small on-screen die sizes.
|
||||
|
||||
The 30 fps idle value is a cap, not a measured mobile frame rate. Continuous
|
||||
rendering uses more GPU/battery than static dice. Low and other themes stay
|
||||
idle, and hidden views stop visual work. No Android SDK/emulator/device is
|
||||
available here, so actual Android WebView compatibility, frame rate and battery
|
||||
use remain unverified. Before release, test in airplane mode on an Android
|
||||
device, switch qualities/players repeatedly, leave and return to the game,
|
||||
background/foreground the app and inspect WebView/logcat output.
|
||||
@@ -1,4 +1,9 @@
|
||||
# Cosmic Black Hole d6 — three static Blender models
|
||||
# Cosmic Black Hole d6 — Blender models and animation
|
||||
|
||||
Low remains static. Medium and high now animate their accretion disks while
|
||||
rolling and at rest. See [animation integration](cosmic-animation.md) for the
|
||||
renderer extension, visibility handling and current validation. The geometry
|
||||
and reproducible static asset export described below are unchanged.
|
||||
|
||||
Select **Trou noir cosmique** in either player's setup theme picker. The public
|
||||
id remains `cosmicBlackHole`, with preview color `#392064`. The existing graphics
|
||||
@@ -73,8 +78,9 @@ move relative to their faces. The 2 cm authoring scale is conceptual and does
|
||||
not change Dice-Box physics or calibrate screen DPI.
|
||||
|
||||
`roll('2d6')`, result extraction, fallback randomness, scoring, turn handling,
|
||||
probabilities, theme persistence and fallback are unchanged. No edits to
|
||||
GameScreen, game stores or the Dice-Box library were needed for this integration.
|
||||
probabilities, theme persistence and fallback are unchanged. Animation adds
|
||||
screen-focus handling to GameScreen and a maintained Dice-Box rendering adapter;
|
||||
game stores and rules remain unchanged.
|
||||
The initial integration also waits for initialization/theme loading before rolls
|
||||
and keeps the latest player's theme in Dice3DViewer's READY callback.
|
||||
|
||||
@@ -128,10 +134,9 @@ Screenshots of all three qualities and rolling dice were inspected.
|
||||
|
||||
## Visual and device limitations
|
||||
|
||||
These are static models. The disk texture gives an illuminated appearance;
|
||||
Blender emission, bloom, lensing and continuous animation are not exported.
|
||||
The game still uses Dice-Box lighting and shadows, so brightness differs from
|
||||
the Blender preview, especially on low. The black sphere stays central and the
|
||||
The geometry is static. Medium/high motion is reproduced in a Babylon shader;
|
||||
Blender node graphs are not directly imported. Low uses Dice-Box lighting and
|
||||
shadows, so brightness differs from the Blender preview. The black sphere stays central and the
|
||||
outlined pips stay readable, including a star in front of the core on face 1.
|
||||
The disk appears thin at edge-on angles. Decorative disks may visually overlap
|
||||
one another or the floor because their extent intentionally exceeds the collider.
|
||||
@@ -140,5 +145,5 @@ No Android SDK, emulator or attached device was available. Desktop file loading
|
||||
and component/store checks do not establish real Android WebView behavior or
|
||||
mobile performance. A device check remains: build/install the app, enable
|
||||
airplane mode, roll both dice at all qualities, switch players/themes, restart,
|
||||
and inspect logcat/WebView for errors. Continuous animation is deferred to the
|
||||
next implementation stage and would require a separate renderer extension.
|
||||
and inspect logcat/WebView for errors. See the animation document for the
|
||||
additional idle-rendering and lifecycle checks.
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"postinstall": "bun run sync:dice"
|
||||
},
|
||||
"dependencies": {
|
||||
"@3d-dice/dice-box": "^1.1.4",
|
||||
"@3d-dice/dice-box": "1.1.4",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"expo": "~55.0.14",
|
||||
|
||||
@@ -37,6 +37,13 @@ try {
|
||||
return open.call(this, method, String(url).replace('file:///android_asset', assetRoot), ...args);
|
||||
};
|
||||
window.messages = [];
|
||||
window.completionCount = 0;
|
||||
window.physicsCommands = {};
|
||||
const post = MessagePort.prototype.postMessage;
|
||||
MessagePort.prototype.postMessage = function (message, ...args) {
|
||||
if (message?.action) window.physicsCommands[message.action] = (window.physicsCommands[message.action] || 0) + 1;
|
||||
return post.call(this, message, ...args);
|
||||
};
|
||||
window.ReactNativeWebView = { postMessage: message => window.messages.push(JSON.parse(message)) };
|
||||
}, { assetRoot: pathToFileURL(assets).href });
|
||||
const html = readFileSync(resolve(assets, 'dice3d.html'), 'utf8').replace(
|
||||
@@ -45,7 +52,10 @@ try {
|
||||
const Original = DiceBoxModule.default;
|
||||
window.boxes = [];
|
||||
DiceBoxModule = {default: class extends Original {
|
||||
constructor(options) { super(options); window.boxes.push(this); }
|
||||
constructor(options) {
|
||||
super({...options, onRollComplete: result => { window.completionCount++; options.onRollComplete?.(result); }});
|
||||
window.boxes.push(this);
|
||||
}
|
||||
}};
|
||||
document.body.style.background = '#080b18';
|
||||
</script><script src="./dice-bridge.js"></script>`,
|
||||
@@ -57,6 +67,7 @@ try {
|
||||
window.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ type, payload }) }));
|
||||
}, { type, payload });
|
||||
async function roll(theme, label, quality) {
|
||||
const completedBefore = await page.evaluate(() => window.completionCount);
|
||||
if (quality) await send('QUALITY', { level: quality });
|
||||
await send('THEME', { theme });
|
||||
await send('ROLL');
|
||||
@@ -66,6 +77,7 @@ try {
|
||||
}
|
||||
await page.waitForFunction(count => window.messages.filter(message => message.type === 'ROLL_RESULT').length > count,
|
||||
results.length, { timeout: 40000 });
|
||||
await page.waitForFunction(() => window.boxes.at(-1).getVisualState()?.idle, null, { timeout: 10000 });
|
||||
const result = await page.evaluate(() => ({
|
||||
bridge: window.messages.filter(message => message.type === 'ROLL_RESULT').at(-1).payload.dice,
|
||||
config: window.boxes.at(-1).config,
|
||||
@@ -85,6 +97,39 @@ try {
|
||||
}
|
||||
if (quality) assert.equal(result.config.scale, { low: 7.5, medium: 8.8, high: 10 }[quality]);
|
||||
await page.screenshot({ path: resolve(output, `${label}.png`) });
|
||||
const before = await page.evaluate(() => ({ visual: window.boxes.at(-1).getVisualState(),
|
||||
commands: { ...window.physicsCommands }, completed: window.completionCount, messages: window.messages.length }));
|
||||
const pixelsBefore = await page.screenshot();
|
||||
await page.waitForTimeout(500);
|
||||
const after = await page.evaluate(() => ({ visual: window.boxes.at(-1).getVisualState(),
|
||||
commands: { ...window.physicsCommands }, completed: window.completionCount, messages: window.messages.length }));
|
||||
const pixelsAfter = await page.screenshot({ path: resolve(output, `${label}-settled.png`) });
|
||||
assert.deepEqual(after.visual.dice, before.visual.dice, 'Settled dice poses/geometry changed');
|
||||
assert.ok(after.visual.dice.every(die => die.shaderReady), 'Shader is not ready');
|
||||
assert.equal(after.visual.engineLoops, 0, 'Physics render loop still running');
|
||||
assert.equal(after.completed, completedBefore + 1, 'Roll completion must happen once');
|
||||
assert.equal(after.messages, before.messages, 'Duplicate bridge result while idle');
|
||||
assert.deepEqual(after.commands, before.commands, 'Idle animation must not step or resume physics');
|
||||
const animated = theme === 'cosmicBlackHole' && quality !== 'low';
|
||||
if (animated) {
|
||||
assert.ok(after.visual.frameId > before.visual.frameId, 'Animated theme stopped at rest');
|
||||
assert.notDeepEqual(pixelsAfter, pixelsBefore, 'Animated pixels did not change');
|
||||
} else {
|
||||
assert.equal(after.visual.frameId, before.visual.frameId, 'Static theme wastes idle frames');
|
||||
assert.deepEqual(pixelsAfter, pixelsBefore, 'Static theme pixels changed');
|
||||
}
|
||||
results.at(-1).idleValidation = { animated, frames: after.visual.frameId - before.visual.frameId, completionCount: after.completed };
|
||||
if (quality === 'high') {
|
||||
await send('VISIBILITY', { active: false });
|
||||
const paused = await page.evaluate(() => window.boxes.at(-1).getVisualState());
|
||||
await page.waitForTimeout(350);
|
||||
const hidden = await page.evaluate(() => window.boxes.at(-1).getVisualState());
|
||||
assert.equal(hidden.frameId, paused.frameId, 'Hidden WebView is still drawing');
|
||||
assert.equal(hidden.seconds, paused.seconds, 'Hidden animation clock is advancing');
|
||||
await send('VISIBILITY', { active: true });
|
||||
await page.waitForFunction(frame => window.boxes.at(-1).getVisualState().frameId > frame, paused.frameId);
|
||||
assert.ok(await page.evaluate(() => window.boxes.slice(0, -1).every(box => box.cosmicDisposed)), 'Old quality worlds were not disposed');
|
||||
}
|
||||
console.log(`${label}: ${result.bridge.join(', ')}`);
|
||||
return result.bridge;
|
||||
}
|
||||
@@ -95,6 +140,11 @@ try {
|
||||
for (const theme of ['default', 'rust', 'blueGreenMetal', 'gemstone', 'gemstoneMarble', 'rock', 'wooden', 'smooth', 'smooth-pip', 'diceOfRolling']) {
|
||||
await roll(theme, `existing-${theme}`);
|
||||
}
|
||||
// Re-enter every quality after third-party themes to catch stale shaders,
|
||||
// cached model reuse and abandoned worker/render-loop errors.
|
||||
for (const quality of ['low', 'medium', 'high']) {
|
||||
for (const value of await roll('cosmicBlackHole', `cosmic-return-${quality}`, quality)) facesSeen.add(value);
|
||||
}
|
||||
// Exercise all six outcomes through real physics, without supplying values.
|
||||
for (let i = 0; i < 24 && facesSeen.size < 6; i++) {
|
||||
for (const value of await roll('cosmicBlackHole', `cosmic-faces-${i}`)) facesSeen.add(value);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve, basename } from 'node:path';
|
||||
|
||||
const hashes = {
|
||||
'world.onscreen.js': 'b7b2d38c5121428ef32a8c2eeb03da0d40b7eb7cd3074e7825dc504f129b2fdf',
|
||||
'dice-box.es.js': 'd1caccae54bd074c84eee2055dbb77073b6a95d294f973e610a71bad4bc59a89',
|
||||
};
|
||||
function once(source, before, after) {
|
||||
if (source.split(before).length !== 2) throw new Error('Dice-Box patch anchor changed: ' + before.slice(0, 90));
|
||||
return source.replace(before, after);
|
||||
}
|
||||
export function patchDiceBox(name, source, root) {
|
||||
if (createHash('sha256').update(source).digest('hex') !== hashes[name]) {
|
||||
throw new Error(`Dice-Box ${name} differs from 1.1.4. Review the animation adapter before upgrading.`);
|
||||
}
|
||||
if (name === 'world.onscreen.js') {
|
||||
source = once(source, 'r = new g(t, this.scene);', 'r = createCosmicMaterial(t, this.scene, g, Re);');
|
||||
source = once(source, 'this.onInitComplete();', 'this.cosmicMotion = new CosmicMotion(C(this, K), C(this, fe), () => Object.values(C(this, Z))), this.onInitComplete();');
|
||||
source = once(source, ' render(e) {', ' render(e) {\n this.cosmicMotion.stopIdle();');
|
||||
source = once(source, 'this.onRollComplete()) : C(this, K).render();',
|
||||
'this.cosmicMotion.startIdle(), this.onRollComplete()) : this.cosmicMotion.renderRolling();');
|
||||
source = once(source, ' clear() {', ' clear() {\n this.cosmicMotion.stopIdle();');
|
||||
source = once(source, ' resize(e) {\n const t = C(this, fe).width', ` dispose() {
|
||||
if (this.cosmicDisposed) return;
|
||||
this.cosmicDisposed = true;
|
||||
C(this, Ve).forEach(clearTimeout);
|
||||
this.cosmicMotion.dispose();
|
||||
C(this, oe).stopRenderLoop();
|
||||
if (C(this, ne)) { C(this, ne).onmessage = null; C(this, ne).close(); }
|
||||
C(this, K).dispose();
|
||||
C(this, oe).dispose();
|
||||
}
|
||||
setVisualsActive(active) { this.cosmicMotion.setActive(active); }
|
||||
getVisualState() {
|
||||
return {
|
||||
disposed: !!this.cosmicDisposed, active: this.cosmicMotion.active,
|
||||
idle: this.cosmicMotion.idle, seconds: this.cosmicMotion.seconds,
|
||||
frameId: C(this, K).getFrameId(), engineLoops: C(this, oe).activeRenderLoops.length,
|
||||
dice: Object.values(C(this, Z)).filter(d => d.mesh).map(d => ({
|
||||
id: d.id, theme: d.config.theme, position: d.mesh.position.asArray(),
|
||||
rotation: d.mesh.rotationQuaternion.asArray(),
|
||||
shaderReady: !!d.mesh.material?.getEffect()?.isReady(),
|
||||
vertices: d.mesh.getTotalVertices()
|
||||
}))
|
||||
};
|
||||
}
|
||||
resize(e) {
|
||||
if (this.cosmicDisposed) return;
|
||||
const t = C(this, fe).width`);
|
||||
source = once(source, 'Oe.loadDie(e, C(this, K)).then((t) => {',
|
||||
'Oe.loadDie(e, C(this, K)).then((t) => {\n if (this.cosmicDisposed) return;');
|
||||
source = once(source, ' requestAnimationFrame(() => {\n C(this, ne).postMessage({',
|
||||
' requestAnimationFrame(() => {\n if (this.cosmicDisposed) return;\n C(this, ne).postMessage({');
|
||||
return `import { createCosmicMaterial } from ${JSON.stringify(resolve(root, 'src/dice-box/cosmic-material.js'))};
|
||||
import { CosmicMotion } from ${JSON.stringify(resolve(root, 'src/dice-box/cosmic-motion.js'))};\n` + source;
|
||||
}
|
||||
source = once(source, ` resizeWorld() {
|
||||
const b = pl(() => {
|
||||
W(this, u).resize({ width: this.canvas.clientWidth, height: this.canvas.clientHeight }), W(this, p) && W(this, p).postMessage({ action: "resize", width: this.canvas.clientWidth, height: this.canvas.clientHeight });
|
||||
});
|
||||
window.addEventListener("resize", b);
|
||||
}`, ` resizeWorld() {
|
||||
if (this.cosmicDisposed) return;
|
||||
if (!this.cosmicResize) {
|
||||
this.cosmicResize = pl(() => {
|
||||
if (this.cosmicDisposed || !this.cosmicReady) return;
|
||||
W(this, u).resize({ width: this.canvas.clientWidth, height: this.canvas.clientHeight });
|
||||
if (W(this, p)) W(this, p).postMessage({ action: "resize", width: this.canvas.clientWidth, height: this.canvas.clientHeight });
|
||||
});
|
||||
window.addEventListener("resize", this.cosmicResize);
|
||||
}
|
||||
this.cosmicResize();
|
||||
}
|
||||
setVisualsActive(active) { W(this, u).setVisualsActive?.(active); }
|
||||
getVisualState() { return W(this, u).getVisualState?.(); }
|
||||
dispose() {
|
||||
if (this.cosmicDisposed) return;
|
||||
this.cosmicDisposed = true;
|
||||
window.removeEventListener("resize", this.cosmicResize);
|
||||
W(this, u).dispose?.();
|
||||
W(this, p)?.terminate();
|
||||
this.canvas?.remove();
|
||||
}`);
|
||||
source = once(source, 'await Promise.all([W(this, w), W(this, K)]),',
|
||||
'await Promise.all([W(this, w), W(this, K)]), this.cosmicReady = true,');
|
||||
return source;
|
||||
}
|
||||
|
||||
export function diceBoxAnimationPlugin(root) {
|
||||
return { name: 'dice-box-cosmic-animation', setup(build) {
|
||||
build.onLoad({ filter: /[\\/]@3d-dice[\\/]dice-box[\\/]dist[\\/](world\.onscreen|dice-box\.es)\.js$/ }, args => ({
|
||||
contents: patchDiceBox(basename(args.path), readFileSync(args.path, 'utf8'), root),
|
||||
loader: 'js', resolveDir: dirname(args.path),
|
||||
}));
|
||||
} };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { build } from 'esbuild';
|
||||
import { cpSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { diceBoxAnimationPlugin } from './patch-dice-box.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -31,6 +32,7 @@ await build({
|
||||
platform: 'browser',
|
||||
target: ['es2015'],
|
||||
logLevel: 'info',
|
||||
plugins: [diceBoxAnimationPlugin(ROOT)],
|
||||
});
|
||||
|
||||
// 2. Copy static assets (ammo WASM, default theme) preserving directory structure
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LayoutChangeEvent, StyleSheet, View } from 'react-native';
|
||||
import { AppState, LayoutChangeEvent, StyleSheet, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import type { QualitySetting } from '../constants/quality';
|
||||
import { parseDicePayload } from '../services/dicePayloadService';
|
||||
@@ -14,6 +14,7 @@ type Dice3DViewerProps = {
|
||||
resetSignal?: number;
|
||||
qualitySetting: QualitySetting;
|
||||
theme?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function Dice3DViewer({
|
||||
@@ -23,6 +24,7 @@ export function Dice3DViewer({
|
||||
resetSignal,
|
||||
qualitySetting,
|
||||
theme,
|
||||
active = true,
|
||||
}: Dice3DViewerProps) {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const lastSignalRef = useRef(rollSignal);
|
||||
@@ -68,6 +70,16 @@ export function Dice3DViewer({
|
||||
onReadyChange?.(webReady);
|
||||
}, [onReadyChange, webReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webReady) return;
|
||||
const sendVisibility = (state: string | null) => postBridgeMessage({
|
||||
type: 'VISIBILITY', payload: { active: active && state !== 'background' && state !== 'inactive' },
|
||||
});
|
||||
sendVisibility(AppState.currentState);
|
||||
const subscription = AppState.addEventListener('change', sendVisibility);
|
||||
return () => subscription.remove();
|
||||
}, [active, postBridgeMessage, webReady]);
|
||||
|
||||
useEffect(() => {
|
||||
postResize();
|
||||
}, [postResize, webViewKey]);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
The d6 collider, transform and face map are derived from @3d-dice/dice-box 1.1.4.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 3Ddice
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,71 @@
|
||||
// Blender motion study translated to a Babylon CustomMaterial. Only disk UVs
|
||||
// leave the white atlas corner; pips/core/outlines use the ordinary material.
|
||||
export const animatedCosmicThemes = new Set(['cosmicBlackHoleMedium', 'cosmicBlackHole']);
|
||||
|
||||
const rotation = `
|
||||
vec2 cosmicRotate(vec2 uv, float angle) {
|
||||
float c = cos(angle), s = sin(angle);
|
||||
return mat2(c, s, -s, c) * (uv - 0.5) + 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const turbulence = `
|
||||
float cosmicHash(vec3 p) {
|
||||
p = fract(p * 0.1031);
|
||||
p += dot(p, p.yzx + 33.33);
|
||||
return fract((p.x + p.y) * p.z);
|
||||
}
|
||||
float cosmicNoise(vec3 p) {
|
||||
vec3 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(mix(cosmicHash(i), cosmicHash(i+vec3(1,0,0)), f.x),
|
||||
mix(cosmicHash(i+vec3(0,1,0)), cosmicHash(i+vec3(1,1,0)), f.x), f.y),
|
||||
mix(mix(cosmicHash(i+vec3(0,0,1)), cosmicHash(i+vec3(1,0,1)), f.x),
|
||||
mix(cosmicHash(i+vec3(0,1,1)), cosmicHash(i+vec3(1,1,1)), f.x), f.y), f.z);
|
||||
}
|
||||
vec3 cosmicEddies(vec2 uv, float phase) {
|
||||
vec3 p = vec3(uv * 7.0 + vec2(cos(phase), sin(phase)), sin(phase*2.0));
|
||||
return vec3(cosmicNoise(p), cosmicNoise(p+vec3(7.1,3.2,1.7)), cosmicNoise(p*2.0+3.7));
|
||||
}
|
||||
`;
|
||||
|
||||
export function createCosmicMaterial(theme, scene, StandardMaterial, CustomMaterial) {
|
||||
if (!animatedCosmicThemes.has(theme)) return new StandardMaterial(theme, scene);
|
||||
const high = theme === 'cosmicBlackHole';
|
||||
const material = new CustomMaterial(theme, scene);
|
||||
material.AddUniform('cosmicPhase', 'float');
|
||||
material.Vertex_Definitions('varying vec2 cosmicUV; varying float cosmicDisk;');
|
||||
material.Vertex_MainEnd('cosmicUV = uv; cosmicDisk = step(0.1, max(uv.x, uv.y));');
|
||||
material.Fragment_Definitions('varying vec2 cosmicUV; varying float cosmicDisk;\n' + rotation + (high ? turbulence : ''));
|
||||
material.Fragment_Before_Fog(`
|
||||
#ifdef DIFFUSE
|
||||
if (cosmicDisk > 0.5) {
|
||||
vec2 a = cosmicRotate(cosmicUV, cosmicPhase * ${high ? '2.0' : '1.0'});
|
||||
vec2 b = cosmicRotate(cosmicUV, cosmicPhase * ${high ? '-3.0' : '2.0'});
|
||||
${high ? `
|
||||
vec3 eddies = cosmicEddies(a, cosmicPhase);
|
||||
vec2 offset = (eddies.xy - 0.5) * 0.06;
|
||||
a += offset;
|
||||
b -= offset;
|
||||
` : ''}
|
||||
vec3 gas = mix(toLinearSpace(texture2D(diffuseSampler, clamp(a, 0.065, 0.995)).rgb),
|
||||
toLinearSpace(texture2D(diffuseSampler, clamp(b, 0.065, 0.995)).rgb), ${high ? '0.46' : '0.38'});
|
||||
${high ? `
|
||||
vec2 centered = cosmicUV - 0.5;
|
||||
float radius = length(centered), angle = atan(centered.y, centered.x);
|
||||
float fade = clamp((0.5-radius)*3.5, 0.0, 1.0);
|
||||
float streak = pow(max(sin(radius*110.0 + angle*5.0 + eddies.z*28.0 - cosmicPhase*7.0), 0.0), 7.0);
|
||||
vec3 plasma = mix(vec3(0.025,0.20,1.0), vec3(0.65,0.018,0.34), eddies.z);
|
||||
gas += plasma * streak * fade * 0.8;
|
||||
float knots = pow(max(sin(angle*3.0 + radius*55.0 + cosmicPhase*4.0), 0.0), 8.0);
|
||||
gas += vec3(0.25,0.06,0.8) * knots * fade * 0.13;
|
||||
` : ''}
|
||||
color.rgb = toGammaSpace(gas * ${high ? '2.4' : '1.8'});
|
||||
}
|
||||
#endif
|
||||
`);
|
||||
material.onBindObservable.add(() => {
|
||||
material.getEffect()?.setFloat('cosmicPhase', (scene.cosmicMotion?.seconds || 0) * Math.PI / 6);
|
||||
});
|
||||
return material;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { animatedCosmicThemes } from './cosmic-material.js';
|
||||
|
||||
// The original Dice-Box loop still stops physics and completes the roll. This
|
||||
// separate loop only redraws settled animated dice; it never calls the worker.
|
||||
export class CosmicMotion {
|
||||
constructor(scene, canvas, getDice, env = globalThis) {
|
||||
this.scene = scene;
|
||||
this.canvas = canvas;
|
||||
this.getDice = getDice;
|
||||
this.env = env;
|
||||
this.seconds = 0;
|
||||
this.idle = false;
|
||||
this.active = true;
|
||||
this.disposed = false;
|
||||
this.frame = null;
|
||||
this.lastTime = null;
|
||||
this.lastDraw = -Infinity;
|
||||
this.visibility = () => {
|
||||
this.lastTime = null;
|
||||
this.cancelFrame();
|
||||
this.schedule();
|
||||
};
|
||||
this.beforeRender = scene.onBeforeRenderObservable.add(() => {
|
||||
const now = env.performance.now();
|
||||
if (this.lastTime !== null && this.visible()) this.seconds = (this.seconds + (now - this.lastTime) / 1000) % 12;
|
||||
this.lastTime = now;
|
||||
});
|
||||
env.document.addEventListener('visibilitychange', this.visibility);
|
||||
scene.cosmicMotion = this;
|
||||
}
|
||||
visible() {
|
||||
return !this.disposed && this.active && this.env.document.visibilityState !== 'hidden' && this.canvas.isConnected;
|
||||
}
|
||||
hasAnimatedDice() {
|
||||
return this.getDice().some(die => die.mesh && !die.mesh.isDisposed() && animatedCosmicThemes.has(die.config.theme));
|
||||
}
|
||||
cancelFrame() {
|
||||
if (this.frame !== null) this.env.cancelAnimationFrame(this.frame);
|
||||
this.frame = null;
|
||||
}
|
||||
schedule() {
|
||||
if (this.frame !== null || !this.idle || !this.visible() || !this.hasAnimatedDice()) return;
|
||||
this.frame = this.env.requestAnimationFrame(now => {
|
||||
this.frame = null;
|
||||
if (!this.idle || !this.visible() || !this.hasAnimatedDice()) return;
|
||||
// Both animated qualities cap idle redraws at 30 fps. Flow speed uses time.
|
||||
if (now - this.lastDraw >= 1000 / 30 - 0.5) {
|
||||
this.lastDraw = now;
|
||||
this.scene.render();
|
||||
}
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
startIdle() {
|
||||
if (this.disposed) return;
|
||||
this.idle = true;
|
||||
this.schedule();
|
||||
}
|
||||
stopIdle() {
|
||||
this.idle = false;
|
||||
this.cancelFrame();
|
||||
}
|
||||
renderRolling() {
|
||||
if (this.visible()) this.scene.render();
|
||||
}
|
||||
setActive(active) {
|
||||
this.active = active;
|
||||
this.visibility();
|
||||
}
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.stopIdle();
|
||||
this.env.document.removeEventListener('visibilitychange', this.visibility);
|
||||
this.scene.onBeforeRenderObservable.remove(this.beforeRender);
|
||||
}
|
||||
}
|
||||
+48
-30
@@ -7,6 +7,7 @@
|
||||
* { type: 'RESIZE' } — recalculate canvas size
|
||||
* { type: 'RESET' } — tear down and reinit
|
||||
* { type: 'QUALITY', payload: { level } } — change quality preset
|
||||
* { type: 'VISIBILITY', payload: { active } } — pause/resume visual animation
|
||||
*
|
||||
* Outbound messages (WebView → RN):
|
||||
* { type: 'READY' } — DiceBox initialised
|
||||
@@ -84,44 +85,52 @@
|
||||
var qualityResetQueued = false;
|
||||
var selectedTheme = 'default';
|
||||
var themeReady = Promise.resolve();
|
||||
var visualsActive = true;
|
||||
|
||||
function ensureDiceBox() {
|
||||
if (diceBoxReady) return diceBoxReady;
|
||||
if (!DiceBox) return Promise.reject(new Error('DiceBox module not loaded'));
|
||||
|
||||
diceBoxReady = (window.__diceReady || Promise.resolve()).then(function () {
|
||||
if (diceBox) return diceBox;
|
||||
|
||||
var cfg = QUALITY_PRESETS[qualityLevel] || QUALITY_PRESETS.high;
|
||||
|
||||
diceBox = new DiceBox({
|
||||
container: '#dice-box',
|
||||
id: 'dice-box-canvas',
|
||||
origin: 'file:///android_asset',
|
||||
assetPath: '/dice-box/assets/',
|
||||
theme: renderTheme(selectedTheme),
|
||||
offscreen: false,
|
||||
scale: cfg.scale,
|
||||
gravity: cfg.gravity,
|
||||
throwForce: cfg.throwForce,
|
||||
spinForce: cfg.spinForce,
|
||||
});
|
||||
|
||||
return diceBox.init().then(function () {
|
||||
healthCheckFailures = 0;
|
||||
return diceBox;
|
||||
});
|
||||
});
|
||||
diceBoxReady = (window.__diceReady || Promise.resolve()).then(createDiceBox);
|
||||
return diceBoxReady;
|
||||
}
|
||||
|
||||
function resetDiceBox() {
|
||||
var container = document.getElementById('dice-box');
|
||||
if (container) container.innerHTML = '';
|
||||
diceBox = null;
|
||||
diceBoxReady = null;
|
||||
function createDiceBox() {
|
||||
var cfg = QUALITY_PRESETS[qualityLevel] || QUALITY_PRESETS.high;
|
||||
|
||||
return ensureDiceBox()
|
||||
diceBox = new DiceBox({
|
||||
container: '#dice-box',
|
||||
id: 'dice-box-canvas',
|
||||
origin: 'file:///android_asset',
|
||||
assetPath: '/dice-box/assets/',
|
||||
theme: renderTheme(selectedTheme),
|
||||
offscreen: false,
|
||||
scale: cfg.scale,
|
||||
gravity: cfg.gravity,
|
||||
throwForce: cfg.throwForce,
|
||||
spinForce: cfg.spinForce,
|
||||
});
|
||||
|
||||
return diceBox.init().then(function () {
|
||||
healthCheckFailures = 0;
|
||||
if (diceBox.setVisualsActive) diceBox.setVisualsActive(visualsActive);
|
||||
return diceBox;
|
||||
});
|
||||
}
|
||||
|
||||
function resetDiceBox() {
|
||||
// Finish initialization before disposal so pending worker init callbacks
|
||||
// cannot recreate an abandoned world after a rapid quality/reset change.
|
||||
var previous = Promise.all([diceBoxReady || Promise.resolve(null), themeReady])
|
||||
.then(function (ready) { return ready[0]; });
|
||||
diceBoxReady = previous.catch(function () { return diceBox; }).then(function (box) {
|
||||
if (box && box.dispose) box.dispose();
|
||||
var container = document.getElementById('dice-box');
|
||||
if (container) container.innerHTML = '';
|
||||
diceBox = null;
|
||||
return createDiceBox();
|
||||
});
|
||||
return diceBoxReady
|
||||
.then(function () { queueResize(); })
|
||||
.catch(function (e) { console.warn('DiceBox reset failed', e); });
|
||||
}
|
||||
@@ -217,7 +226,10 @@
|
||||
function applyTheme(theme) {
|
||||
if (!theme || typeof theme !== 'string') return;
|
||||
selectedTheme = theme;
|
||||
themeReady = themeReady.then(function () { return ensureDiceBox(); })
|
||||
// Capture this world's initialization before queuing the update. Resets
|
||||
// wait for this update, then dispose it; later updates target the new world.
|
||||
var boxReady = ensureDiceBox();
|
||||
themeReady = themeReady.then(function () { return boxReady; })
|
||||
.then(function (box) {
|
||||
return box.updateConfig({ theme: renderTheme(theme) });
|
||||
})
|
||||
@@ -235,6 +247,12 @@
|
||||
case 'RESET': resetDiceBox(); break;
|
||||
case 'QUALITY': applyQualityLevel(msg.payload && msg.payload.level); break;
|
||||
case 'THEME': applyTheme(msg.payload && msg.payload.theme); break;
|
||||
case 'VISIBILITY':
|
||||
visualsActive = !msg.payload || msg.payload.active !== false;
|
||||
ensureDiceBox().then(function (box) {
|
||||
if (box.setVisualsActive) box.setVisualsActive(visualsActive);
|
||||
}).catch(function () { /* noop */ });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useFocusEffect, useIsFocused } from '@react-navigation/native';
|
||||
import { Accelerometer } from 'expo-sensors';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ const createRubTracker = (): RubTracker => ({
|
||||
});
|
||||
|
||||
export function GameScreen({ navigation }: GameScreenProps) {
|
||||
const isScreenFocused = useIsFocused();
|
||||
const {
|
||||
phase,
|
||||
roundSummary,
|
||||
@@ -398,6 +399,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
|
||||
|
||||
<View style={styles.diceWrapper}>
|
||||
<Dice3DViewer
|
||||
active={isScreenFocused}
|
||||
rollSignal={rollSignal}
|
||||
resetSignal={diceResetSignal}
|
||||
onRollResult={onDiceResult}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { appStateListeners } from '../preload';
|
||||
|
||||
const sent: any[] = [];
|
||||
mock.module('react-native-webview', () => ({
|
||||
@@ -26,6 +27,13 @@ describe('Dice3DViewer player theme', () => {
|
||||
await act(async () => { webview().props.onMessage({ nativeEvent: { data: JSON.stringify({ type: 'READY' }) } }); });
|
||||
expect(sent.filter(m => m.type === 'THEME').every(m => m.payload.theme === 'cosmicBlackHole')).toBe(true);
|
||||
expect(sent).toContainEqual({ type: 'THEME', payload: { theme: 'cosmicBlackHole' } });
|
||||
expect(sent).toContainEqual({ type: 'VISIBILITY', payload: { active: true } });
|
||||
await act(async () => { appStateListeners.forEach(listener => listener('background')); });
|
||||
expect(sent.at(-1)).toEqual({ type: 'VISIBILITY', payload: { active: false } });
|
||||
await act(async () => { appStateListeners.forEach(listener => listener('active')); });
|
||||
expect(sent.at(-1)).toEqual({ type: 'VISIBILITY', payload: { active: true } });
|
||||
await act(async () => { tree!.update(<Dice3DViewer active={false} rollSignal={0} qualitySetting="high" theme="cosmicBlackHole" onRollResult={onResult} />); });
|
||||
expect(sent.at(-1)).toEqual({ type: 'VISIBILITY', payload: { active: false } });
|
||||
await act(async () => { tree!.update(<Dice3DViewer rollSignal={1} qualitySetting="low" theme="rust" onRollResult={onResult} />); });
|
||||
expect(sent).toContainEqual({ type: 'THEME', payload: { theme: 'rust' } });
|
||||
expect(sent).toContainEqual({ type: 'ROLL' });
|
||||
@@ -34,6 +42,7 @@ describe('Dice3DViewer player theme', () => {
|
||||
expect(onResult).toHaveBeenCalledWith([2,5]);
|
||||
} finally {
|
||||
await act(async () => { tree?.unmount(); });
|
||||
expect(appStateListeners.size).toBe(0);
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
}
|
||||
});
|
||||
|
||||
+10
-1
@@ -18,6 +18,8 @@ const Text = createPrimitive('Text');
|
||||
const ScrollView = createPrimitive('ScrollView');
|
||||
const ActivityIndicator = createPrimitive('ActivityIndicator');
|
||||
|
||||
export const appStateListeners = new Set<(state: string) => void>();
|
||||
|
||||
const Pressable = ({ children, ...props }: Record<string, unknown>) => {
|
||||
const resolvedChildren =
|
||||
typeof children === 'function' ? (children as (state: { pressed: boolean }) => unknown)({ pressed: false }) : children;
|
||||
@@ -40,6 +42,13 @@ mock.module('react-native', () => ({
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
AppState: {
|
||||
currentState: 'active',
|
||||
addEventListener: (_event: string, callback: (state: string) => void) => {
|
||||
appStateListeners.add(callback);
|
||||
return { remove: () => appStateListeners.delete(callback) };
|
||||
},
|
||||
},
|
||||
StyleSheet: {
|
||||
create: (styles: Record<string, unknown>) => styles,
|
||||
flatten: (style: unknown) => style,
|
||||
@@ -65,4 +74,4 @@ mock.module('react-native', () => ({
|
||||
mock.module('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: createPrimitive('SafeAreaView'),
|
||||
SafeAreaProvider: createPrimitive('SafeAreaProvider'),
|
||||
}));
|
||||
}));
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { CosmicMotion } from '../../src/dice-box/cosmic-motion.js';
|
||||
import { createCosmicMaterial } from '../../src/dice-box/cosmic-material.js';
|
||||
import { patchDiceBox } from '../../scripts/patch-dice-box.mjs';
|
||||
|
||||
function harness(theme = 'cosmicBlackHole') {
|
||||
let now = 0, id = 0, renders = 0;
|
||||
const frames = new Map(), listeners = new Set(), before = new Set();
|
||||
const canvas = { isConnected: true };
|
||||
const dice = [{ config: { theme }, mesh: { isDisposed: () => false } }];
|
||||
const env = { performance: { now: () => now },
|
||||
document: { visibilityState: 'visible', addEventListener: (_, f) => listeners.add(f), removeEventListener: (_, f) => listeners.delete(f) },
|
||||
requestAnimationFrame: f => { frames.set(++id, f); return id; }, cancelAnimationFrame: i => frames.delete(i) };
|
||||
const scene = { onBeforeRenderObservable: { add: f => { before.add(f); return f; }, remove: f => before.delete(f) },
|
||||
render: () => { before.forEach(f => f()); renders++; } };
|
||||
const motion = new CosmicMotion(scene, canvas, () => dice, env);
|
||||
return { motion, canvas, frames, listeners, before, dice, env, scene,
|
||||
get renders() { return renders; },
|
||||
tick(ms) { now += ms; const queued = [...frames.values()]; frames.clear(); queued.forEach(f => f(now)); },
|
||||
hide(hidden) { env.document.visibilityState = hidden ? 'hidden' : 'visible'; listeners.forEach(f => f()); } };
|
||||
}
|
||||
|
||||
describe('cosmic animation lifecycle', () => {
|
||||
for (const theme of ['cosmicBlackHoleLow', 'default', 'rust']) {
|
||||
it(`${theme} schedules no idle rendering`, () => {
|
||||
const h = harness(theme); h.motion.startIdle(); h.tick(100);
|
||||
expect(h.renders).toBe(0); expect(h.frames.size).toBe(0);
|
||||
h.motion.dispose();
|
||||
});
|
||||
}
|
||||
it('redraws medium/high at 30 fps with elapsed-time flow and one pending frame', () => {
|
||||
const h = harness('cosmicBlackHoleMedium');
|
||||
h.scene.render(); h.motion.startIdle(); h.motion.startIdle();
|
||||
expect(h.frames.size).toBe(1);
|
||||
for (let i = 0; i < 60; i++) h.tick(1000 / 60);
|
||||
expect(h.renders).toBeGreaterThanOrEqual(30); expect(h.renders).toBeLessThanOrEqual(32);
|
||||
expect(h.motion.seconds).toBeCloseTo(59 / 60, 5);
|
||||
h.motion.stopIdle(); expect(h.frames.size).toBe(0);
|
||||
});
|
||||
it('pauses the clock for document visibility and native screen/app visibility', () => {
|
||||
const h = harness(); h.motion.startIdle(); h.tick(40); h.tick(40);
|
||||
const time = h.motion.seconds, count = h.renders;
|
||||
h.hide(true); h.tick(5000);
|
||||
expect(h.frames.size).toBe(0); expect(h.renders).toBe(count);
|
||||
h.hide(false); h.tick(40);
|
||||
expect(h.motion.seconds).toBe(time); // resume without a five-second jump
|
||||
h.motion.setActive(false); h.tick(5000); h.motion.renderRolling();
|
||||
expect(h.motion.seconds).toBe(time);
|
||||
h.motion.setActive(true); h.tick(40); h.tick(40);
|
||||
expect(h.motion.seconds).toBeCloseTo(time + 0.04, 5);
|
||||
});
|
||||
it('stops when the canvas is detached or the last animated die is removed', () => {
|
||||
const h = harness(); h.motion.startIdle(); h.canvas.isConnected = false; h.tick(40);
|
||||
expect(h.frames.size).toBe(0); expect(h.renders).toBe(0);
|
||||
h.canvas.isConnected = true; h.motion.startIdle(); h.dice.length = 0; h.tick(40);
|
||||
expect(h.frames.size).toBe(0);
|
||||
});
|
||||
it('disposes callbacks idempotently and cannot restart abandoned animation', () => {
|
||||
const h = harness(); h.motion.startIdle(); h.motion.dispose(); h.motion.dispose();
|
||||
h.motion.startIdle(); h.tick(500);
|
||||
expect(h.frames.size).toBe(0); expect(h.listeners.size).toBe(0); expect(h.before.size).toBe(0);
|
||||
expect(h.renders).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('material and version boundary', () => {
|
||||
it('leaves low and third-party themes on the original standard material', () => {
|
||||
class Standard { constructor(name) { this.name = name; } }
|
||||
class Unsupported { constructor() { throw new Error('Unexpected custom shader'); } }
|
||||
for (const theme of ['cosmicBlackHoleLow', 'default', 'rust']) {
|
||||
expect(createCosmicMaterial(theme, {}, Standard, Unsupported)).toBeInstanceOf(Standard);
|
||||
}
|
||||
});
|
||||
it('keeps shader motion off the atlas corner used by stars/core/outlines', () => {
|
||||
class Custom {
|
||||
onBindObservable = { add: f => { this.bind = f; } };
|
||||
AddUniform(name) { this.uniform = name; }
|
||||
Vertex_Definitions(s) { this.varyings = s; }
|
||||
Vertex_MainEnd(s) { this.vertex = s; }
|
||||
Fragment_Definitions(s) { this.definitions = s; }
|
||||
Fragment_Before_Fog(s) { this.fragment = s; }
|
||||
getEffect() { return { setFloat: (key, value) => { this.value = [key, value]; } }; }
|
||||
}
|
||||
const scene = { cosmicMotion: { seconds: 3 } };
|
||||
for (const theme of ['cosmicBlackHoleMedium', 'cosmicBlackHole']) {
|
||||
const material = createCosmicMaterial(theme, scene, null, Custom);
|
||||
expect(material.vertex).toContain('step(0.1, max(uv.x, uv.y))');
|
||||
expect(material.fragment).toContain('if (cosmicDisk > 0.5)');
|
||||
expect(material.vertex).not.toContain('positionUpdated');
|
||||
expect(material.definitions.includes('cosmicNoise')).toBe(theme === 'cosmicBlackHole');
|
||||
material.bind(); expect(material.value[1]).toBeCloseTo(Math.PI / 2, 6);
|
||||
}
|
||||
});
|
||||
it('patches only the pinned, verified library and rejects unknown builds', () => {
|
||||
const root = resolve(import.meta.dir, '../..');
|
||||
for (const name of ['world.onscreen.js', 'dice-box.es.js']) {
|
||||
const original = readFileSync(resolve(root, 'node_modules/@3d-dice/dice-box/dist', name), 'utf8');
|
||||
const patched = patchDiceBox(name, original, root);
|
||||
expect(patched).toContain('getVisualState()');
|
||||
expect(patched).toContain('dispose()');
|
||||
expect(() => patchDiceBox(name, original + '\n', root)).toThrow('differs from 1.1.4');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ function harness() {
|
||||
class FakeBox {
|
||||
config: any;
|
||||
rolls: string[] = [];
|
||||
disposed = false;
|
||||
active = true;
|
||||
constructor(config: any) { this.config = config; boxes.push(this); }
|
||||
init() { return delayInit ? new Promise<void>(r => { releaseInit = r; }) : Promise.resolve(); }
|
||||
updateConfig(config: any) {
|
||||
@@ -29,6 +31,8 @@ function harness() {
|
||||
.then(() => [{ value: 1 }, { value: 6 }]);
|
||||
}
|
||||
resizeWorld() {}
|
||||
dispose() { this.disposed = true; }
|
||||
setVisualsActive(active: boolean) { this.active = active; }
|
||||
}
|
||||
runInNewContext(source, {
|
||||
DiceBoxModule: { default: FakeBox }, console, setInterval: () => 0,
|
||||
@@ -44,6 +48,26 @@ function harness() {
|
||||
}
|
||||
|
||||
describe('dice theme bridge lifecycle', () => {
|
||||
it('finishes an in-flight theme load before disposing its renderer on quality change', async () => {
|
||||
const h = harness(); await flush(); h.delayTheme();
|
||||
h.send('THEME', { theme: 'cosmicBlackHole' }); await flush();
|
||||
h.send('QUALITY', { level: 'low' }); h.send('ROLL'); await flush();
|
||||
expect(h.boxes[0].disposed).toBe(false);
|
||||
h.finishTheme(); await flush();
|
||||
expect(h.boxes[0].disposed).toBe(true);
|
||||
expect(h.boxes.at(-1)?.config.theme).toBe('cosmicBlackHoleLow');
|
||||
expect(h.boxes.at(-1)?.rolls).toEqual(['2d6']);
|
||||
});
|
||||
it('disposes old worlds and keeps native visibility across quality resets', async () => {
|
||||
const h = harness(); await flush();
|
||||
h.send('VISIBILITY', { active: false }); await flush();
|
||||
expect(h.boxes[0].active).toBe(false);
|
||||
h.send('QUALITY', { level: 'low' }); await flush();
|
||||
expect(h.boxes[0].disposed).toBe(true);
|
||||
expect(h.boxes.at(-1)?.active).toBe(false);
|
||||
h.send('VISIBILITY', { active: true }); await flush();
|
||||
expect(h.boxes.at(-1)?.active).toBe(true);
|
||||
});
|
||||
it('waits for theme loading before rolling both dice with unchanged results', async () => {
|
||||
const h = harness(); await flush(); h.delayTheme();
|
||||
h.send('THEME', { theme: 'cosmicBlackHole' }); h.send('ROLL'); await flush();
|
||||
|
||||
Reference in new issue
Block a user