Animate cosmic dice without keeping physics running

This commit is contained in:
Codex committed 2026-09-07 22:27:25 +02:00
1 parent 320b8a9cb0
commit a5e70e1f86
18 files changed
+664 -45

No files matched your search

+1 -1
View File
@@ -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",
+114
View File
@@ -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.
+14 -9
View File
@@ -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
View File
@@ -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",
+51 -1
View File
@@ -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);
+97
View File
@@ -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),
}));
} };
}
+2
View File
@@ -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
+13 -1
View File
@@ -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]);
+23
View File
@@ -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.
+71
View File
@@ -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;
}
+77
View File
@@ -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
View File
@@ -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;
}
}
+3 -1
View File
@@ -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}
+9
View File
@@ -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
View File
@@ -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'),
}));
}));
+106
View File
@@ -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');
}
});
});
+24
View File
@@ -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();