nuts / commit
lots of small changes
index.html | 10 +++ src/debug.ts | 5 ++ src/main.ts | 108 ++++++++++++++++++++++++--- src/persistence.ts | 3 + src/physics.ts | 137 ++++++++++++++-------------------- src/renderer.ts | 73 ++++++++---------- src/rockTemplates.ts | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/style.css | 110 +++++++++++++++++++++++++++ src/ui.ts | 89 +++++++++++++++++++++- src/vibration.ts | 29 +++++--- 10 files changed, 619 insertions(+), 150 deletions(-) diff --git a/index.html b/index.html index 09fe973..26e920d 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,16 @@ <span id="nut-size-display" class="size-pill-value">130</span> </div> + <!-- Slow-frame recovery overlay --> + <div id="perf-overlay" hidden> + <div class="perf-box"> + <p class="perf-msg">The scene is taking too long to simulate.<br>Consider reducing the number of items. + </p> + <button id="perf-reset-btn" class="perf-btn">Clear & reset</button> + <button id="perf-continue-btn" class="perf-btn perf-btn-secondary">Continue anyway</button> + </div> + </div> + <!-- Settings panel --> <div id="settings-panel" hidden> <div class="panel-backdrop"></div> diff --git a/src/debug.ts b/src/debug.ts index f827aa7..6d0bbbd 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -80,3 +80,8 @@ export function dbgError(...args: unknown[]): void { display(msg, true) sendToServer('error', msg) } + +export function remoteLog(msg: string): void { + if (import.meta.env.PROD) return + sendToServer('log', msg) +} diff --git a/src/main.ts b/src/main.ts index 33c294c..71e630b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import { createPhysicsWorld } from './physics' import { createRenderer } from './renderer' import { requestMotionPermission, initMotion } from './motion' import { initUI, removeStartOverlay, type ItemKind, type GravityMode } from './ui' -import { setFlashFallback, unlockVibration, setVibrateEnabled } from './vibration' +import { unlockVibration, setVibrateEnabled, setVibrateItems, setVibrateEdges } from './vibration' import { dbg, dbgError } from './debug' import { checkCompat, showUnsupportedWall } from './compat' import { @@ -44,9 +44,6 @@ async function main(): Promise<void> { const physics = createPhysicsWorld(cssWidth, cssHeight) const renderer = createRenderer(canvas) - // Wire visual flash fallback for browsers without Vibration API (e.g. Firefox) - setFlashFallback(() => renderer.flash()) - // Correct for the DPR scale we already applied to the context // The renderer draws at CSS px resolution; canvas is scaled by dpr above renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr) @@ -55,7 +52,11 @@ async function main(): Promise<void> { let tapsLocked = false let gravityMode: GravityMode = 'device' let vibrateEnabled = true + let vibrateItemsEnabled = true + let vibrateEdgesEnabled = true let currentKind: ItemKind = 'random' + const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock'] + let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL] // ── Restore persisted settings ──────────────────────────────────────── const savedSettings = loadSettings() @@ -68,10 +69,24 @@ async function main(): Promise<void> { vibrateEnabled = false setVibrateEnabled(false) } + if (savedSettings.vibrateItems === false) { + vibrateItemsEnabled = false + setVibrateItems(false) + } + if (savedSettings.vibrateEdges === false) { + vibrateEdgesEnabled = false + setVibrateEdges(false) + } const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock'] if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) { currentKind = savedSettings.kind as ItemKind } + if (Array.isArray(savedSettings.randomPool)) { + const valid = savedSettings.randomPool.filter(k => + (validKinds as string[]).includes(k) && k !== 'random' + ) as ItemKind[] + if (valid.length > 0) randomPool = valid + } } // ── Restore nut positions (share URL takes priority over snapshot) ──────── @@ -121,10 +136,13 @@ async function main(): Promise<void> { initialLocked: tapsLocked, initialGravityMode: gravityMode, initialVibrateEnabled: vibrateEnabled, + initialVibrateItems: vibrateItemsEnabled, + initialVibrateEdges: vibrateEdgesEnabled, initialKind: currentKind, + initialRandomPool: randomPool, onSizeChange: (radius) => { currentNutRadius = radius - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) }, onClear: () => { physics.clearNuts() @@ -132,20 +150,34 @@ async function main(): Promise<void> { }, onLockChange: (locked) => { tapsLocked = locked - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) }, onKindChange: (kind) => { currentKind = kind - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) }, onGravityModeChange: (mode) => { gravityMode = mode - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) }, onVibrateChange: (enabled) => { vibrateEnabled = enabled - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) + }, + onVibrateItemsChange: (enabled) => { + vibrateItemsEnabled = enabled + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) + }, + onVibrateEdgesChange: (enabled) => { + vibrateEdgesEnabled = enabled + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) }, + onRandomPoolChange: (pool) => { + randomPool = pool + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) + }, + onSettingsOpen: () => { settingsPaused = true }, + onSettingsClose: () => { settingsPaused = false }, getShareURL: () => buildShareURL(physics.nuts, currentNutRadius), }) @@ -166,8 +198,8 @@ async function main(): Promise<void> { if (hasSpace(cx, cy, currentNutRadius)) { let kind = currentKind if (kind === 'random') { - const rand = Math.random() - kind = rand < 0.20 ? 'balloon' : rand < 0.38 ? 'gem' : rand < 0.50 ? 'metalnut' : rand < 0.65 ? 'rock' : 'nut' + const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock'] as ItemKind[] + kind = pool[Math.floor(Math.random() * pool.length)] } if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius) else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius) @@ -296,9 +328,33 @@ async function main(): Promise<void> { }, 400) } + // ── Slow-frame recovery ─────────────────────────────────────────────── + const perfOverlay = document.getElementById('perf-overlay')! + const perfResetBtn = document.getElementById('perf-reset-btn')! + const perfContinueBtn = document.getElementById('perf-continue-btn')! + let perfPaused = false + const SLOW_FRAME_MS = 5000 + + perfResetBtn.addEventListener('click', () => { + physics.clearNuts() + clearSnapshot() + perfOverlay.hidden = true + perfPaused = false + lastTimestamp = 0 + requestAnimationFrame(loop) + }) + + perfContinueBtn.addEventListener('click', () => { + perfOverlay.hidden = true + perfPaused = false + lastTimestamp = 0 + requestAnimationFrame(loop) + }) + // ── Game loop ───────────────────────────────────────────────────────────── let lastTimestamp = 0 let loopRunning = false + let settingsPaused = false function startLoop(): void { if (loopRunning) return @@ -307,9 +363,37 @@ async function main(): Promise<void> { } function loop(timestamp: number): void { + // Pause when settings are open + if (settingsPaused) { + lastTimestamp = 0 + requestAnimationFrame(loop) + return + } + + // On mobile: pause physics + vibration when not fullscreen + const isMobile = 'ontouchstart' in window + const isFullscreen = !!document.fullscreenElement + if (isMobile && !isFullscreen) { + setVibrateEnabled(false) + lastTimestamp = 0 + requestAnimationFrame(loop) + return + } + if (isMobile && vibrateEnabled) { + setVibrateEnabled(true) + } + const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp lastTimestamp = timestamp + // Detect runaway frame time — tab wake-up or physics explosion + if (delta > SLOW_FRAME_MS) { + perfOverlay.hidden = false + perfPaused = true + loopRunning = false + return + } + // ── Auto-gravity modes ─────────────────────────────────────────────── if (gravityMode === 'tumbler') { // Full 360° rotation at 0.1 Hz (10 seconds per revolution) @@ -338,7 +422,7 @@ async function main(): Promise<void> { // Also save on tab close / navigation window.addEventListener('pagehide', () => { saveSnapshot(physics.nuts, cssWidth, cssHeight) - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) }) } diff --git a/src/persistence.ts b/src/persistence.ts index c560eba..05cc5f4 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -12,7 +12,10 @@ export interface PersistedSettings { nutRadius: number gravityMode?: string vibrateEnabled?: boolean + vibrateItems?: boolean + vibrateEdges?: boolean kind?: string + randomPool?: string[] } export function loadSettings(): PersistedSettings | null { diff --git a/src/physics.ts b/src/physics.ts index b3fb85b..53d6c2c 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -1,7 +1,8 @@ import Matter from 'matter-js' import { CONFIG } from './config' -import { vibrate } from './vibration' +import { vibrate, vibrateWall } from './vibration' import { dbg } from './debug' +import { ROCK_TEMPLATES } from './rockTemplates' const { Engine, Bodies, Body, Composite, Events, Vector } = Matter @@ -13,7 +14,9 @@ export interface NutBody { colorIndex: number kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' gemVariant: number // gems: 0=diamond…4=amethyst balloons: colorIndex - hitCount: number // rocks: number of qualifying impacts received + hitCount: number // rocks: accumulated damage + templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks + toughness: number // rocks: damage multiplier (0.5=brittle … 2=hard); 1 for non-rocks } export interface PhysicsWorld { @@ -66,7 +69,7 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): frictionAir: CONFIG.frictionAir, label: 'nut', }) - return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0 } + return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 } } function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { @@ -80,7 +83,7 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb frictionAir: 0.0008, label: 'metalnut', }) - return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0 } + return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 } } const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant @@ -99,7 +102,7 @@ function spawnGem(x: number, y: number, radius: number, gemVariant: number): Nut frictionAir: 0.0005, label: 'gem', }) - return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0 } + return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1 } } function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody { @@ -112,7 +115,7 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): frictionAir: 0.008, label: 'balloon', }) - return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0 } + return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 } } // ── Factory ──────────────────────────────────────────────────────────────── @@ -124,25 +127,20 @@ const ROCK_HITS_TO_SPLIT = 10 // hits required to break function spawnRock(x: number, y: number, targetRadius: number, colorIndex: number): NutBody { const variance = 0.85 + Math.random() * 0.3 const radius = targetRadius * variance - const sides = 5 + Math.floor(Math.random() * 4) // 5–8 sides - - // Irregular convex polygon: evenly-spaced base angles + small jitter - const verts: Matter.Vector[] = [] - for (let i = 0; i < sides; i++) { - const baseAngle = (i / sides) * Math.PI * 2 - const jitter = (Math.random() - 0.5) * (Math.PI / sides) * 0.85 - const r = radius * (0.6 + Math.random() * 0.65) - verts.push(Vector.create(Math.cos(baseAngle + jitter) * r, Math.sin(baseAngle + jitter) * r)) - } + const templateIdx = Math.floor(Math.random() * ROCK_TEMPLATES.length) + const template = ROCK_TEMPLATES[templateIdx] + const verts = template.normVerts.map(v => Vector.create(v.x * radius, v.y * radius)) const body = Bodies.fromVertices(x, y, [verts], { - density: 0.0025, // granite-like: ~2.5× default - restitution: 0.25, // rocks don't bounce much + density: 0.0025, + restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock', }) - return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0 } + // Toughness: log-uniform from 0.5 (brittle) to 2.0 (hard) + const toughness = Math.pow(2, (Math.random() - 0.5) * 2) // 2^[-1, 1] = 0.5 … 2.0 + return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0, templateIdx, toughness } } export function createPhysicsWorld(width: number, height: number): PhysicsWorld { @@ -156,10 +154,6 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld let currentHeight = height let splitQueue: NutBody[] = [] const splittingIds = new Set<number>() - let targetRockMass = 0 - let rockAddCount = 0 - let rockAddRadiusSum = 0 - let refillRetryAt = 0 // engine timestamp after which to try again Composite.add(engine.world, walls) @@ -194,19 +188,23 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld // Queue rock splits before vibration so we have speed info const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed) - if (speed >= ROCK_SPLIT_SPEED) { - const tryQueueSplit = (b: Matter.Body) => { - if (b.label !== 'rock') return - if (splittingIds.has(b.id)) return - const nb = nuts.find(n => n.body === b) + if (speed > ROCK_SPLIT_SPEED) { + const tryQueueSplit = (rockBody: Matter.Body, otherBody: Matter.Body) => { + if (rockBody.label !== 'rock') return + if (splittingIds.has(rockBody.id)) return + const nb = nuts.find(n => n.body === rockBody) if (!nb) return - nb.hitCount++ + // Damage = excess kinetic energy of impactor relative to rock mass. + // Normalised so 10 hits at ROCK_SPLIT_SPEED*1.5 from equal mass ≈ 10. + const excess = speed - ROCK_SPLIT_SPEED + const massRatio = otherBody.mass / rockBody.mass + nb.hitCount += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness) if (nb.hitCount < ROCK_HITS_TO_SPLIT) return - splittingIds.add(b.id) + splittingIds.add(rockBody.id) splitQueue.push(nb) } - if (aRock) tryQueueSplit(pair.bodyA) - if (bRock) tryQueueSplit(pair.bodyB) + if (aRock) tryQueueSplit(pair.bodyA, pair.bodyB) + if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA) } if (aNut && bNut) { @@ -237,13 +235,13 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld if (wallSpeed > CONFIG.impactThreshold) { if (nutBody.label === 'rock') { const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1) - vibrate(Math.round(15 + t * 30)) + vibrateWall(Math.round(15 + t * 30)) } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') { const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1) - vibrate(Math.round(15 + t * 35)) + vibrateWall(Math.round(15 + t * 35)) } else { const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1) - vibrate(Math.round(30 + t * (CONFIG.maxVibrationMs - 30))) + vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30))) } } } @@ -289,39 +287,6 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Body.setAngle(rock.body, Math.random() * Math.PI * 2) nuts.push(rock) Composite.add(engine.world, rock.body) - targetRockMass += rock.body.mass - rockAddRadiusSum += radius - rockAddCount++ - } - - function refillRocks(): void { - if (targetRockMass === 0) return - const now = engine.timing.timestamp - if (now < refillRetryAt) return - const currentMass = nuts.filter(n => n.kind === 'rock').reduce((s, n) => s + n.body.mass, 0) - const avgRadius = rockAddRadiusSum / rockAddCount - const bigRockRefMass = Math.PI * avgRadius * avgRadius * 0.0025 - if (currentMass >= targetRockMass - bigRockRefMass) return - - const spawnRadius = avgRadius - const clearance = spawnRadius * 2.2 // minimum gap around spawn point - const x = currentWidth / 2 + (Math.random() - 0.5) * currentWidth * 0.1 - const y = currentHeight / 2 + (Math.random() - 0.5) * currentHeight * 0.1 - const blocked = nuts.some(n => { - const dx = n.body.position.x - x - const dy = n.body.position.y - y - return Math.sqrt(dx * dx + dy * dy) < clearance + n.radius - }) - if (blocked) { - refillRetryAt = now + 1500 // retry in 1.5 s - return - } - - const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT) - const rock = spawnRock(x, y, spawnRadius, colorIndex) - Body.setAngle(rock.body, Math.random() * Math.PI * 2) - nuts.push(rock) - Composite.add(engine.world, rock.body) } function processSplits(): void { @@ -331,26 +296,35 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld splittingIds.delete(nut.body.id) const idx = nuts.indexOf(nut) if (idx === -1) continue - const { position, velocity } = nut.body + + const pos = { x: nut.body.position.x, y: nut.body.position.y } + const vel = { x: nut.body.velocity.x, y: nut.body.velocity.y } + Composite.remove(engine.world, nut.body) nuts.splice(idx, 1) if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue - const count = nut.radius > CONFIG.nutMinRadius * 3 ? 3 : 2 - const newRadius = (nut.radius / Math.sqrt(count)) * 0.9 + // Spawn smaller rocks preserving total area (≈ mass at constant density) + // More pieces for larger rocks: 2 at min size, up to 5 at max size + const sizeFraction = Math.min(1, (nut.radius - CONFIG.nutMinRadius) / (CONFIG.nutMaxRadius - CONFIG.nutMinRadius)) + const count = Math.round(2 + sizeFraction * 3) // 2–5 fragments + // r² × count = R² → r = R / √count + const fragRadius = (nut.radius / Math.sqrt(count)) * 0.95 + for (let i = 0; i < count; i++) { - const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.8 - const dist = nut.radius * 0.3 - const fx = position.x + Math.cos(spreadAngle) * dist - const fy = position.y + Math.sin(spreadAngle) * dist - const fragment = spawnRock(fx, fy, newRadius, nut.colorIndex) - Body.setVelocity(fragment.body, { - x: velocity.x + Math.cos(spreadAngle) * 3.5, - y: velocity.y + Math.sin(spreadAngle) * 3.5, + if (fragRadius < CONFIG.nutMinRadius * 0.5) continue + const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.5 + const dist = nut.radius * 0.35 + const fx = pos.x + Math.cos(spreadAngle) * dist + const fy = pos.y + Math.sin(spreadAngle) * dist + const frag = spawnRock(fx, fy, fragRadius, nut.colorIndex) + Body.setVelocity(frag.body, { + x: vel.x + Math.cos(spreadAngle) * (2 + Math.random() * 2), + y: vel.y + Math.sin(spreadAngle) * (2 + Math.random() * 2), }) - nuts.push(fragment) - Composite.add(engine.world, fragment.body) + nuts.push(frag) + Composite.add(engine.world, frag.body) } } } @@ -401,7 +375,6 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld function step(deltaMs: number): void { Engine.update(engine, Math.min(deltaMs, 50)) processSplits() - refillRocks() } function destroy(): void { diff --git a/src/renderer.ts b/src/renderer.ts index aa2cb80..327012f 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -2,6 +2,7 @@ import type { NutBody } from './physics' import Matter from 'matter-js' import { CONFIG } from './config' import QRCode from 'qrcode' +import { ROCK_TEMPLATES } from './rockTemplates' // ── Nut palette – warm tones ─────────────────────────────────────────────── interface NutColor { hi: string; mid: string; shadow: string; rim: string } @@ -331,7 +332,7 @@ const ROCK_PALETTE = [ { fill: '#705540', hi: '#987860', dark: '#483020', stroke: '#2C1810' }, ] -function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number): void { +function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number, templateIdx: number): void { const c = ROCK_PALETTE[colorIndex % ROCK_PALETTE.length] const verts = body.vertices const cx = body.position.x @@ -381,44 +382,41 @@ function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: numb ctx.fill() ctx.restore() - // Cracks — stable per body ID, grow with hit progress + // Cracks — template-based, vertex-to-vertex through shared fracture point const HITS_TO_SPLIT = 10 const progress = hitCount / HITS_TO_SPLIT if (progress >= 0.1) { - let s = (body.id * 2654435761) >>> 0 - const rng = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 0x100000000 } - const numCracks = Math.min(Math.floor(progress * 5) + 1, 5) + const template = ROCK_TEMPLATES[templateIdx % ROCK_TEMPLATES.length] + const numCracks = Math.min(Math.floor(progress * template.cracks.length) + 1, template.cracks.length) + + const cos = Math.cos(body.angle), sin = Math.sin(body.angle) + const tw = (lx: number, ly: number) => ({ + x: cx + (lx * radius) * cos - (ly * radius) * sin, + y: cy + (lx * radius) * sin + (ly * radius) * cos, + }) + const fw = tw(template.fracture.x, template.fracture.y) + ctx.save() rockPath() ctx.clip() ctx.lineCap = 'round' - ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress)) + for (let i = 0; i < numCracks; i++) { - const startAngle = rng() * Math.PI * 2 - const startDist = rng() * radius * 0.25 - const crackAngle = rng() * Math.PI * 2 - const crackLen = radius * (0.35 + rng() * 0.45) * (0.4 + progress * 0.6) - const segs = 2 + Math.floor(rng() * 2) - const sx = cx + Math.cos(startAngle) * startDist - const sy = cy + Math.sin(startAngle) * startDist - // dark inner crack - ctx.strokeStyle = `rgba(0,0,0,${0.55 + progress * 0.3})` + const [v0l, v1l] = template.cracks[i] + const v0 = tw(v0l.x, v0l.y) + const v1 = tw(v1l.x, v1l.y) + + const lw = Math.max(0.8, radius * 0.035 * (0.4 + progress)) + ctx.lineWidth = lw + ctx.strokeStyle = `rgba(0,0,0,${0.5 + progress * 0.35})` ctx.beginPath() - ctx.moveTo(sx, sy) - for (let j = 1; j <= segs; j++) { - const t = j / segs - const jitter = (rng() - 0.5) * radius * 0.18 - ctx.lineTo( - sx + Math.cos(crackAngle) * crackLen * t + Math.cos(crackAngle + Math.PI / 2) * jitter, - sy + Math.sin(crackAngle) * crackLen * t + Math.sin(crackAngle + Math.PI / 2) * jitter - ) - } + ctx.moveTo(v0.x, v0.y) + ctx.lineTo(fw.x, fw.y) + ctx.lineTo(v1.x, v1.y) ctx.stroke() - // thin light edge to give depth - ctx.strokeStyle = `rgba(255,255,255,${0.12 + progress * 0.08})` - ctx.lineWidth = Math.max(0.4, radius * 0.012) + ctx.lineWidth = Math.max(0.4, lw * 0.35) + ctx.strokeStyle = `rgba(255,255,255,${0.1 + progress * 0.1})` ctx.stroke() - ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress)) } ctx.restore() } @@ -530,12 +528,10 @@ export interface Renderer { render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean) => void setShareURL: (url: string) => void resize: (width: number, height: number) => void - flash: () => void } export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! - let flashAlpha = 0 let qrBitmap: HTMLCanvasElement | null = null let qrPending = false let lastURL = '' @@ -566,10 +562,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { } } - function flash(): void { - flashAlpha = 0.35 - } - function render(nuts: NutBody[], gravityAngle: number, showArrow = false): void { const w = canvas.width const h = canvas.height @@ -579,7 +571,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.fillRect(0, 0, w, h) // Nuts, gems & balloons - for (const { body, radius, colorIndex, kind, gemVariant, hitCount } of nuts) { + for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx } of nuts) { const { position, angle } = body if (kind === 'balloon') { @@ -589,7 +581,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { drawBalloon(ctx, 0, 0, radius, colorIndex) ctx.restore() } else if (kind === 'rock') { - drawRock(ctx, body, radius, colorIndex, hitCount) + drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx) } else { const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant) ctx.save() @@ -643,13 +635,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.restore() } - - // Flash overlay (fallback haptic cue for Firefox / no-vibration devices) - if (flashAlpha > 0) { - ctx.fillStyle = `rgba(255,255,255,${flashAlpha.toFixed(3)})` - ctx.fillRect(0, 0, w, h) - flashAlpha = Math.max(0, flashAlpha - 0.05) - } } function resize(width: number, height: number): void { @@ -657,5 +642,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { canvas.height = height } - return { render, setShareURL, resize, flash } + return { render, setShareURL, resize } } diff --git a/src/rockTemplates.ts b/src/rockTemplates.ts new file mode 100644 index 0000000..cd28099 --- /dev/null +++ b/src/rockTemplates.ts @@ -0,0 +1,205 @@ +/** + * Precomputed rock fracture templates. + * + * Each template defines: + * - The polygon shape (normVerts, bounding radius = 1, centroid at origin) + * - A shared fracture point + * - Crack endpoint pairs (for the renderer) + * - Pre-split fragment polygons (for physics) + * + * Templates are generated once at module load with thorough boundary checks so + * that every fragment is guaranteed to be physics-valid before any rock is even + * spawned. + */ + +import Matter from 'matter-js' +const { Bodies, Vector } = Matter + +// ── Public types ──────────────────────────────────────────────────────────── + +export interface Vec2 { x: number; y: number } + +export interface RockFragment { + /** Fragment centroid in template-normalised local space (bounding radius = 1) */ + centroid: Vec2 + /** Fragment polygon vertices RELATIVE to this fragment's centroid, same scale */ + localVerts: Vec2[] + /** Bounding radius of this fragment in normalised space */ + boundR: number +} + +export interface RockTemplate { + /** Polygon vertices, centroid at origin, bounding radius = 1 */ + normVerts: Vec2[] + /** Fracture point in template-local space */ + fracture: Vec2 + /** Crack endpoint pairs in template-local space (same scale as normVerts) */ + cracks: [Vec2, Vec2][] + /** Pre-validated fragment polygons */ + fragments: RockFragment[] +} + +// ── Geometry helpers ──────────────────────────────────────────────────────── + +function polygonArea(verts: Vec2[]): number { + let a = 0 + const n = verts.length + for (let i = 0, j = n - 1; i < n; j = i++) { + a += verts[j].x * verts[i].y - verts[i].x * verts[j].y + } + return Math.abs(a) / 2 +} + +/** Proper area-weighted centroid (shoelace). */ +function polygonCentroid(verts: Vec2[]): Vec2 { + let ax = 0, ay = 0, area = 0 + const n = verts.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const c = verts[j].x * verts[i].y - verts[i].x * verts[j].y + ax += (verts[j].x + verts[i].x) * c + ay += (verts[j].y + verts[i].y) * c + area += c + } + area /= 2 + if (Math.abs(area) < 1e-12) return { x: 0, y: 0 } + return { x: ax / (6 * area), y: ay / (6 * area) } +} + +function pointInPolygon(pt: Vec2, poly: Vec2[]): boolean { + let inside = false + const n = poly.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = poly[i].x, yi = poly[i].y + const xj = poly[j].x, yj = poly[j].y + if ((yi > pt.y) !== (yj > pt.y) && + pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi) { + inside = !inside + } + } + return inside +} + +function makeRng(seed: number) { + let s = (seed * 2654435761 + 1) >>> 0 + return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 0x100000000 } +} + +// ── Template builder ──────────────────────────────────────────────────────── + +const MIN_FRAG_AREA = 0.04 // normalised-space minimum fragment area +const MIN_FRAG_BOUND_R = 0.08 // minimum fragment bounding radius + +function buildTemplate(seed: number): RockTemplate | null { + const rng = makeRng(seed) + + // 1. Generate raw polygon (same distribution as spawnRock) + const sides = 5 + Math.floor(rng() * 4) // 5–8 sides + const rawVerts: Vec2[] = [] + for (let i = 0; i < sides; i++) { + const baseAngle = (i / sides) * Math.PI * 2 + const jitter = (rng() - 0.5) * (Math.PI / sides) * 0.85 + const r = 0.6 + rng() * 0.65 + rawVerts.push({ x: Math.cos(baseAngle + jitter) * r, y: Math.sin(baseAngle + jitter) * r }) + } + + // 2. Canonicalise via Matter.js — get actual body vertex order & centroid correction + const mVerts = rawVerts.map(v => Vector.create(v.x, v.y)) + const testBody = Bodies.fromVertices(0, 0, [mVerts], { + density: 0.0025, restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock', + }) + if (!testBody || testBody.vertices.length < 4) return null + + // 3. Extract centroid-relative local verts from the canonicalised body + const n = testBody.vertices.length + const bodyLocalVerts: Vec2[] = testBody.vertices.map(v => ({ + x: v.x - testBody.position.x, + y: v.y - testBody.position.y, + })) + + // 4. Normalise to bounding radius = 1 + const maxR = Math.max(...bodyLocalVerts.map(v => Math.sqrt(v.x * v.x + v.y * v.y))) + if (maxR < 0.1) return null + const normVerts: Vec2[] = bodyLocalVerts.map(v => ({ x: v.x / maxR, y: v.y / maxR })) + + // 5. Area check on the original polygon + if (polygonArea(normVerts) < 0.6) return null + + // 6. Fracture point — must land inside the polygon + const fx = (rng() - 0.5) * 0.3 + const fy = (rng() - 0.5) * 0.3 + const fracture: Vec2 = { x: fx, y: fy } + if (!pointInPolygon(fracture, normVerts)) return null + + // 7. Generate crack pairs + const usedPairs = new Set<string>() + const cracks: [Vec2, Vec2][] = [] + const cutIndices: number[] = [] + + for (let attempt = 0; attempt < 30 && cracks.length < 3; attempt++) { + const startIdx = Math.floor(rng() * n) + const half = Math.floor(n / 2) + const endIdx = (startIdx + half + (rng() < 0.5 ? 0 : 1)) % n + if (startIdx === endIdx) continue + const key = `${Math.min(startIdx, endIdx)}-${Math.max(startIdx, endIdx)}` + if (usedPairs.has(key)) continue + usedPairs.add(key) + cracks.push([{ ...normVerts[startIdx] }, { ...normVerts[endIdx] }]) + if (!cutIndices.includes(startIdx)) cutIndices.push(startIdx) + if (!cutIndices.includes(endIdx)) cutIndices.push(endIdx) + } + + if (cracks.length < 2 || cutIndices.length < 2) return null + cutIndices.sort((a, b) => a - b) + + // 8. Build and validate fragment polygons + const fragments: RockFragment[] = [] + for (let i = 0; i < cutIndices.length; i++) { + const fromIdx = cutIndices[i] + const toIdx = cutIndices[(i + 1) % cutIndices.length] + + // Arc of vertices from fromIdx → toIdx (inclusive) plus the shared fracture point + const fv: Vec2[] = [{ ...fracture }] + let vi = fromIdx + while (true) { + fv.push({ ...normVerts[vi] }) + if (vi === toIdx) break + vi = (vi + 1) % n + } + + if (fv.length < 3) return null + + // Area check + if (polygonArea(fv) < MIN_FRAG_AREA) return null + + // Centroid and centroid-relative verts + const c = polygonCentroid(fv) + const localVerts = fv.map(v => ({ x: v.x - c.x, y: v.y - c.y })) + const boundR = Math.max(...localVerts.map(v => Math.sqrt(v.x * v.x + v.y * v.y))) + if (boundR < MIN_FRAG_BOUND_R) return null + + // Test that Matter.js can actually create this fragment body + const testFragVerts = localVerts.map(v => Vector.create(v.x, v.y)) + const testFrag = Bodies.fromVertices(0, 0, [testFragVerts], { + density: 0.0025, restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock', + }) + if (!testFrag || testFrag.vertices.length < 3) return null + + fragments.push({ centroid: c, localVerts, boundR }) + } + + if (fragments.length < 2) return null + + return { normVerts, fracture, cracks, fragments } +} + +// ── Build the pool ───────────────────────────────────────────────────────── + +export const ROCK_TEMPLATES: readonly RockTemplate[] = (() => { + const templates: RockTemplate[] = [] + let seed = 0 + while (templates.length < 10 && seed < 5000) { + const t = buildTemplate(seed++) + if (t) templates.push(t) + } + return templates +})() diff --git a/src/style.css b/src/style.css index b5ba3ef..9752bdf 100644 --- a/src/style.css +++ b/src/style.css @@ -365,6 +365,50 @@ input[type="range"]::-moz-range-thumb { color: #444; } +/* ── Random pool section ─────────────────────────────────── */ +.pool-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.pool-label { + font-size: 13px; + color: #888; + margin: 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.pool-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.pool-btn { + background: #2a2a2a; + border: 1px solid #3a3a3a; + color: #888; + font-size: 13px; + padding: 9px 6px; + border-radius: 9px; + cursor: pointer; + font-family: inherit; + text-align: center; + transition: background 0.15s, color 0.15s; +} + +.pool-btn.active { + background: #1e2e1e; + border-color: #4a7a4a; + color: #c0e0c0; +} + +.pool-btn:active { + opacity: 0.8; +} + .attribution { font-size: 12px; color: #555; @@ -377,6 +421,72 @@ input[type="range"]::-moz-range-thumb { text-underline-offset: 2px; } +.no-vibration-note { + font-size: 13px; + color: #666; + margin: 0; + line-height: 1.45; +} + +/* ── Slow-frame recovery overlay ───────────────────────────── */ +#perf-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; +} + +#perf-overlay[hidden] { + display: none; +} + +.perf-box { + background: #1e1e1e; + border-radius: 16px; + padding: 28px 24px; + max-width: 320px; + width: calc(100% - 48px); + display: flex; + flex-direction: column; + gap: 14px; + font-family: system-ui, -apple-system, sans-serif; +} + +.perf-msg { + color: #ccc; + font-size: 15px; + line-height: 1.5; + margin: 0; + text-align: center; +} + +.perf-btn { + background: #c8a84b; + border: none; + color: #111; + font-size: 15px; + font-weight: 600; + padding: 13px 16px; + border-radius: 11px; + cursor: pointer; + font-family: inherit; + transition: opacity 0.15s; +} + +.perf-btn:active { + opacity: 0.8; +} + +.perf-btn-secondary { + background: #2a2a2a; + border: 1px solid #3a3a3a; + color: #aaa; + font-weight: 400; +} + /* ── Share section ─────────────────────────────────────────────────────── */ .share-section { display: flex; diff --git a/src/ui.ts b/src/ui.ts index ad40100..e84a7ce 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2,7 +2,7 @@ import { CONFIG } from './config' import { dbg } from './debug' import QRCode from 'qrcode' import { hasVibration } from './compat' -import { setVibrateEnabled, isVibrateEnabled } from './vibration' +import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration' export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' export type GravityMode = 'device' | 'tumbler' | 'rock' @@ -22,13 +22,21 @@ export interface UICallbacks { initialLocked: boolean initialGravityMode: GravityMode initialVibrateEnabled: boolean + initialVibrateItems: boolean + initialVibrateEdges: boolean initialKind: ItemKind + initialRandomPool: ItemKind[] onSizeChange: (radius: number) => void onClear: () => void onLockChange: (locked: boolean) => void onKindChange: (kind: ItemKind) => void onGravityModeChange: (mode: GravityMode) => void onVibrateChange: (enabled: boolean) => void + onVibrateItemsChange: (enabled: boolean) => void + onVibrateEdgesChange: (enabled: boolean) => void + onRandomPoolChange: (pool: ItemKind[]) => void + onSettingsOpen: () => void + onSettingsClose: () => void getShareURL: () => string } @@ -138,15 +146,22 @@ export function initUI(callbacks: UICallbacks): void { if (!hasVibration()) { testVibrateBtn.hidden = true vibrateToggleBtn.hidden = true + const noVibNote = document.createElement('p') + noVibNote.className = 'no-vibration-note' + noVibNote.textContent = '📳 Haptic feedback is available on Android and some other devices.' + vibrateToggleBtn.insertAdjacentElement('afterend', noVibNote) } else { // Restore persisted vibration state setVibrateEnabled(callbacks.initialVibrateEnabled) + setVibrateItems(callbacks.initialVibrateItems) + setVibrateEdges(callbacks.initialVibrateEdges) vibrateToggleBtn.textContent = callbacks.initialVibrateEnabled ? 'Vibration: On' : 'Vibration: Off' vibrateToggleBtn.classList.toggle('active', callbacks.initialVibrateEnabled) } settingsBtn.addEventListener('click', () => { settingsPanel.hidden = false + callbacks.onSettingsOpen() refreshQR() }) @@ -169,6 +184,77 @@ export function initUI(callbacks: UICallbacks): void { callbacks.onVibrateChange(on) }) + // ── Vibration sub-options (only shown when vibration available) ────────── + if (hasVibration()) { + const vibrateItemsBtn = document.createElement('button') + vibrateItemsBtn.className = 'action-btn' + (callbacks.initialVibrateItems ? ' active' : '') + vibrateItemsBtn.textContent = callbacks.initialVibrateItems ? 'Item hits: On' : 'Item hits: Off' + vibrateItemsBtn.addEventListener('click', () => { + const on = !isVibrateItems() + setVibrateItems(on) + vibrateItemsBtn.textContent = on ? 'Item hits: On' : 'Item hits: Off' + vibrateItemsBtn.classList.toggle('active', on) + callbacks.onVibrateItemsChange(on) + }) + + const vibrateEdgesBtn = document.createElement('button') + vibrateEdgesBtn.className = 'action-btn' + (callbacks.initialVibrateEdges ? ' active' : '') + vibrateEdgesBtn.textContent = callbacks.initialVibrateEdges ? 'Edge hits: On' : 'Edge hits: Off' + vibrateEdgesBtn.addEventListener('click', () => { + const on = !isVibrateEdges() + setVibrateEdges(on) + vibrateEdgesBtn.textContent = on ? 'Edge hits: On' : 'Edge hits: Off' + vibrateEdgesBtn.classList.toggle('active', on) + callbacks.onVibrateEdgesChange(on) + }) + + // Insert after vibrateToggleBtn + vibrateToggleBtn.insertAdjacentElement('afterend', vibrateEdgesBtn) + vibrateToggleBtn.insertAdjacentElement('afterend', vibrateItemsBtn) + } + + // ── Random pool toggles ────────────────────────────────────────────────── + const RANDOM_KINDS: Array<{ kind: Exclude<ItemKind, 'random'>; label: string }> = [ + { kind: 'nut', label: '🌰 Nuts' }, + { kind: 'gem', label: '💎 Gems' }, + { kind: 'balloon', label: '🎈 Balloons' }, + { kind: 'metalnut', label: '🔩 Metal nuts' }, + { kind: 'rock', label: '🪨 Rocks' }, + ] + let randomPool = new Set<ItemKind>(callbacks.initialRandomPool) + + const poolSection = document.createElement('div') + poolSection.className = 'pool-section' + const poolLabel = document.createElement('p') + poolLabel.className = 'pool-label' + poolLabel.textContent = '? random includes' + poolSection.appendChild(poolLabel) + const poolGrid = document.createElement('div') + poolGrid.className = 'pool-grid' + + for (const { kind, label } of RANDOM_KINDS) { + const btn = document.createElement('button') + btn.className = 'pool-btn' + (randomPool.has(kind) ? ' active' : '') + btn.textContent = label + btn.addEventListener('click', () => { + if (randomPool.has(kind)) { + if (randomPool.size <= 1) return // keep at least one + randomPool.delete(kind) + btn.classList.remove('active') + } else { + randomPool.add(kind) + btn.classList.add('active') + } + callbacks.onRandomPoolChange([...randomPool]) + }) + poolGrid.appendChild(btn) + } + poolSection.appendChild(poolGrid) + + // Insert before the close button + const closeBtn2 = settingsPanel.querySelector('#close-settings') as HTMLButtonElement + closeBtn2.parentElement!.insertBefore(poolSection, closeBtn2) + copyLinkBtn.addEventListener('click', () => { const url = callbacks.getShareURL() const orig = copyLinkBtn.textContent @@ -198,6 +284,7 @@ export function initUI(callbacks: UICallbacks): void { function closePanel(): void { settingsPanel.hidden = true + callbacks.onSettingsClose() } } diff --git a/src/vibration.ts b/src/vibration.ts index 734767e..47ab91f 100644 --- a/src/vibration.ts +++ b/src/vibration.ts @@ -2,14 +2,10 @@ import { CONFIG } from './config' import { dbg } from './debug' let lastVibrationAt = 0 -let flashFn: (() => void) | undefined let unlocked = false let vibrateEnabled = true - -/** Register a visual fallback called when the Vibration API is unavailable. */ -export function setFlashFallback(fn: () => void): void { - flashFn = fn -} +let vibrateItems = true +let vibrateEdges = true export function setVibrateEnabled(on: boolean): void { vibrateEnabled = on @@ -19,6 +15,11 @@ export function isVibrateEnabled(): boolean { return vibrateEnabled } +export function setVibrateItems(on: boolean): void { vibrateItems = on } +export function isVibrateItems(): boolean { return vibrateItems } +export function setVibrateEdges(on: boolean): void { vibrateEdges = on } +export function isVibrateEdges(): boolean { return vibrateEdges } + /** * Call once from a user-gesture handler (e.g. the tap-to-start). * Chrome requires at least one vibrate() call from a gesture context before @@ -60,6 +61,7 @@ function canVibrate(): boolean { /** Fire the Web Vibration API, with a minimum interval to avoid spam. */ export function vibrate(durationMs: number): void { + if (!vibrateItems) return const now = Date.now() if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return // Chrome won't vibrate if the page is backgrounded or display is off @@ -69,12 +71,19 @@ export function vibrate(durationMs: number): void { if (canVibrate()) { const ok = navigator.vibrate(durationMs) dbg('vibrate', durationMs, 'ms ->', ok) - } else { - dbg('vibrate: fallback flash (unlocked:', unlocked, ')') - flashFn?.() } } +export function vibrateWall(durationMs: number): void { + if (!vibrateEdges) return + const now = Date.now() + if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return + if (document.visibilityState !== 'visible') return + lastVibrationAt = now + if (!vibrateEnabled) return + if (canVibrate()) navigator.vibrate(durationMs) +} + /** Short double-pulse for a shake event. */ export function vibrateShake(): void { const now = Date.now() @@ -83,7 +92,5 @@ export function vibrateShake(): void { if (!vibrateEnabled) return if (canVibrate()) { navigator.vibrate([25, 15, 25]) - } else { - flashFn?.() } }