You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The module starts long-lived async work (scene.registerBeforeRender, recursive requestAnimationFrame in animateFill, and multiple setTimeout calls in particle triggers) but does not keep handles to unregister/cancel them on dispose(). This can lead to callbacks running after meshes/scene are disposed, causing leaks and intermittent runtime errors. Consider storing the beforeRender observer and timeout/RAF IDs and clearing/removing them in dispose() (and potentially in reset()).
// Animation loopscene.registerBeforeRender(()=>{sloshIntensity*=0.97sloshPhase+=0.08if(currentFillLevel>0.01){constliquidHeight=currentFillLevel*maxLiquidHeightconstbaseY=0.15+liquidHeightconstambientWave=Math.sin(sloshPhase)*0.02+Math.sin(sloshPhase*1.7)*0.01constsloshWave=Math.sin(sloshPhase*2)*sloshIntensity*0.15liquidCapMesh.position.y=baseY+ambientWave+sloshWaveliquidCapMesh.rotation.x=Math.PI/2+Math.sin(sloshPhase*0.8)*sloshIntensity*0.08liquidCapMesh.rotation.z=Math.sin(sloshPhase*0.5)*sloshIntensity*0.06}})// Smooth fill animationlettargetFillLevel=0letfillAnimating=falseconstanimateFill=()=>{if(!fillAnimating)returnconstdiff=targetFillLevel-currentFillLevelif(Math.abs(diff)<0.001){currentFillLevel=targetFillLevelfillAnimating=false}else{currentFillLevel+=diff*0.06}consthasLiquid=currentFillLevel>0.01liquidMesh.isVisible=hasLiquidliquidCapMesh.isVisible=hasLiquidconstscaleY=Math.max(0.001,currentFillLevel)liquidMesh.scaling.y=scaleYliquidMesh.position.y=0.15constliquidHeight=Math.max(0.01,currentFillLevel*maxLiquidHeight)liquidCapMesh.position.y=0.15+liquidHeightconstcapScale=currentFillLevel<0.2 ? 0.8+currentFillLevel*1.0 : 1.0liquidCapMesh.scaling.x=capScaleliquidCapMesh.scaling.z=capScaleif(fillAnimating){requestAnimationFrame(animateFill)}}return{updateFill: (level: number)=>{targetFillLevel=Math.max(0,Math.min(1,level))if(!fillAnimating){fillAnimating=trueanimateFill()}},setColor: (fruitOrLevel: FruitDef|CEFRLevel)=>{constfruitColors=typeoffruitOrLevel==="string" ? LEVEL_FRUIT_COLORS[fruitOrLevel] : fruitOrLevelcurrentColor=hexToColor3(fruitColors.primary)liquidMaterial.diffuseColor=currentColorliquidMaterial.emissiveColor=currentColor.scale(0.35)if(liquidMaterial.emissiveFresnelParameters){liquidMaterial.emissiveFresnelParameters.leftColor=currentColor.scale(0.5)}// Update all particle systems with new colorconstallParticles=[squeezeParticles,overflowParticles,splashParticles]allParticles.forEach((ps)=>{ps.color1=newColor4(currentColor.r,currentColor.g,currentColor.b,1)ps.color2=newColor4(currentColor.r*1.2,currentColor.g*1.2,currentColor.b,1)ps.colorDead=newColor4(currentColor.r,currentColor.g,currentColor.b,0)})},triggerSqueeze: ()=>{// Juice spray burstsqueezeParticles.emitRate=400setTimeout(()=>{squeezeParticles.emitRate=150},200)setTimeout(()=>{squeezeParticles.emitRate=0},500)sloshIntensity=1.2// Internal splashconstliquidHeight=currentFillLevel*maxLiquidHeightconstsplashY=0.15+liquidHeight+originalLayoutYsplashParticles.emitter=newVector3(0,splashY*(originalLayoutScale?.y||1),5)setTimeout(()=>{splashParticles.emitRate=250setTimeout(()=>{splashParticles.emitRate=0},350)},150)},triggerOverflow: ()=>{// Big juice explosion!overflowParticles.emitRate=500setTimeout(()=>{overflowParticles.emitRate=200},400)setTimeout(()=>{overflowParticles.emitRate=0},1200)sloshIntensity=1.5},reset: ()=>{currentFillLevel=0targetFillLevel=0fillAnimating=falseliquidMesh.scaling.y=0.001liquidMesh.position.y=0.15liquidMesh.isVisible=falseliquidCapMesh.position.y=0.15liquidCapMesh.scaling.x=0.8liquidCapMesh.scaling.z=0.8liquidCapMesh.rotation.x=Math.PI/2liquidCapMesh.rotation.z=0liquidCapMesh.isVisible=falseif(glassMaterial){glassMaterial.alpha=0.25}sloshIntensity=0bottleContainer.scaling=originalLayoutScale.clone()bottleContainer.position.x=0bottleContainer.position.y=originalLayoutY},updateLayout: (_worldWidth: number,worldHeight: number)=>{consttargetHeight=worldHeight*1.2constbottleNaturalHeight=7.4constscale=targetHeight/bottleNaturalHeightoriginalLayoutScale=newVector3(scale,scale,scale)originalLayoutY=-worldHeight*0.2bottleContainer.scaling=originalLayoutScale.clone()bottleContainer.position.y=originalLayoutY},dispose: ()=>{squeezeParticles.dispose()overflowParticles.dispose()splashParticles.dispose()particleTexture.dispose()liquidCapMesh.dispose()liquidMesh.dispose()bottleMesh.dispose()bottleContainer.dispose()},
The persisted state includes full bottleProgress, which contains bottleCollection and nested phrases. Over time this can grow without bounds and potentially exceed localStorage quota or degrade startup performance. Consider capping history sizes (e.g., keep last N bottles/phrases), persisting only summaries, or moving detailed phrase history to a different storage mechanism.
handleWin records wins and plays effects, but there’s no visible guard to prevent multiple invocations for the same solved phrase (e.g., if win detection fires repeatedly while the correct arrangement remains). This could inflate score/streaks and over-advance bottle progress. Consider debouncing/locking on a per-phrase basis (e.g., check/store hasWon/completed phrase id before calling recordWin) and/or ensuring useWinDetection only triggers once per phrase.
// Win handlerconsthandleWin=useCallback(()=>{playSuccessSound()constwordCount=phrase.correctWords.lengthconstfruit=getCurrentFruit()// Check bottle state before recording winconstbottleProgress=useGameStore.getState().bottleProgressconstwasLevelComplete=isLevelComplete()constphrasesBeforeWin=bottleProgress.phrasesInCurrentBottlerecordWin(wordCount,phrase.targetText&&phrase.blockText&&phrase.targetLang&&phrase.blockLang
? {targetText: phrase.targetText,blockText: phrase.blockText,targetLang: phrase.targetLang,blockLang: phrase.blockLang,}
: undefined,fruit.gradient)// Speak the completed phraseif(phrase.blockLang&&phrase.blockText){speakWithDelay(phrase.blockLang,phrase.blockText,800)}// Only show level complete modal when:// 1. A bottle was just completed (phrases went from 9 to 0)// 2. That bottle completion caused level to become complete (wasn't complete before)setTimeout(()=>{constbottleJustCompleted=phrasesBeforeWin===9constisNowLevelComplete=isLevelComplete()if(bottleJustCompleted&&isNowLevelComplete&&!wasLevelComplete){setShowLevelComplete(true)}},100)},[phrase,recordWin,getCurrentFruit,speakWithDelay,isLevelComplete,playSuccessSound])useWinDetection({onWin: handleWin})
The particle systems use fixed world-space Vector3 emitters, so resizing/layout scaling will desync the spray/splash from the bottle. Use a mesh (parented to bottleContainer) as the particle emitter and move that mesh in local space based on the liquid height.
-squeezeParticles.emitter = new Vector3(0, 7.5, 5)+// Emitter meshes so particles follow bottle transforms/layout+const neckEmitter = new Mesh("neck-emitter", scene)+neckEmitter.parent = bottleContainer+neckEmitter.position = new Vector3(0, 7.45, 0)++const overflowEmitter = new Mesh("overflow-emitter", scene)+overflowEmitter.parent = bottleContainer+overflowEmitter.position = new Vector3(0, 7.25, 0)++const splashEmitter = new Mesh("splash-emitter", scene)+splashEmitter.parent = bottleContainer+splashEmitter.position = new Vector3(0, 2.0, 0)++// Squeeze particles - juice spray from top+const squeezeParticles = new ParticleSystem("squeeze-particles", 500, scene)+squeezeParticles.particleTexture = particleTexture+squeezeParticles.billboardMode = ParticleSystem.BILLBOARDMODE_ALL+squeezeParticles.blendMode = ParticleSystem.BLENDMODE_STANDARD+squeezeParticles.emitter = neckEmitter+squeezeParticles.minEmitBox = new Vector3(-0.5, 0, -0.5)+squeezeParticles.maxEmitBox = new Vector3(0.5, 0.3, 0.5)
...
-overflowParticles.emitter = new Vector3(0, 7.3, 5)++// Overflow particles - bigger burst when bottle is full+const overflowParticles = new ParticleSystem("overflow-particles", 600, scene)+overflowParticles.particleTexture = particleTexture+overflowParticles.billboardMode = ParticleSystem.BILLBOARDMODE_ALL+overflowParticles.blendMode = ParticleSystem.BLENDMODE_STANDARD+overflowParticles.emitter = overflowEmitter+overflowParticles.minEmitBox = new Vector3(-1.0, 0, -0.5)+overflowParticles.maxEmitBox = new Vector3(1.0, 0.5, 0.5)
...
-splashParticles.emitter = new Vector3(0, 2, 5)++// Splash particles - internal splash when juice rises+const splashParticles = new ParticleSystem("splash-particles", 200, scene)+splashParticles.particleTexture = particleTexture+splashParticles.billboardMode = ParticleSystem.BILLBOARDMODE_ALL+splashParticles.blendMode = ParticleSystem.BLENDMODE_STANDARD+splashParticles.emitter = splashEmitter+splashParticles.minEmitBox = new Vector3(-1.2, 0, -1.2)+splashParticles.maxEmitBox = new Vector3(1.2, 0, 1.2)
...
-const splashY = 0.15 + liquidHeight + originalLayoutY-splashParticles.emitter = new Vector3(0, splashY * (originalLayoutScale?.y || 1), 5)+triggerSqueeze: () => {+ ...+ const liquidHeight = currentFillLevel * maxLiquidHeight+ splashEmitter.position.y = 0.15 + liquidHeight++ setTimeout(() => {+ splashParticles.emitRate = 250+ setTimeout(() => { splashParticles.emitRate = 0 }, 350)+ }, 150)+},+
Suggestion importance[1-10]: 7
__
Why: Using fixed world-space Vector3 emitters will desync particles from bottleContainer when updateLayout() changes scaling/position, so switching to mesh-based emitters parented to bottleContainer is a solid functional fix (though the new emitter meshes would also need disposal to avoid leaks).
Medium
Cancel timers on disposal
setTimeout callbacks can fire after dispose(), mutating disposed particle systems and causing runtime errors. Track timeout IDs per trigger and clear them in dispose() (and also clear any pending timers before starting a new trigger).
Why: Clearing scheduled setTimeout callbacks prevents post-dispose() mutations of ParticleSystem instances, which can otherwise cause runtime errors during unmounts or rapid re-triggers; the proposed clearTimers() approach matches the existing control flow.
Medium
Make dev process shutdown robust
Avoid calling process.exit() directly from the child exit handler, since it can leave the other spawned process orphaned. Also make the python command and termination signals cross-platform to prevent the dev script from failing on Windows. Centralize shutdown so any child exit triggers a clean teardown of both processes.
Why: The current run() calls process.exit() from a child exit handler, which can leave the other spawned process running (or otherwise skip a coordinated teardown). Centralizing shutdown and using a Windows-friendly Python command improves reliability of scripts/dev-corpan.mjs across platforms.
Medium
Forward pointer events to DnD
Your custom pointer handlers override dnd-kit's onPointerMove/onPointerUp, which can break dragging because the library no longer receives those events. Wrap and forward the original listeners handlers for all pointer events (and avoid casting the React event to a DOM PointerEvent).
const handlePointerDown = (e: React.PointerEvent) => {
dragStarted.current = false
pointerStartPos.current = { x: e.clientX, y: e.clientY }
+
// Speak the word immediately on touch/click
onSpeak?.(block.word)
- // Call original listener- listeners?.onPointerDown?.(e as unknown as PointerEvent)++ // Forward to dnd-kit+ listeners?.onPointerDown?.(e)
}
const handlePointerMove = (e: React.PointerEvent) => {
if (pointerStartPos.current) {
const dx = Math.abs(e.clientX - pointerStartPos.current.x)
const dy = Math.abs(e.clientY - pointerStartPos.current.y)
- // If moved more than 5px, consider it a drag
if (dx > 5 || dy > 5) {
dragStarted.current = true
}
}
++ // Forward to dnd-kit+ listeners?.onPointerMove?.(e)
}
-const handlePointerUp = () => {+const handlePointerUp = (e: React.PointerEvent) => {+ // Forward to dnd-kit first so it can finalize the drag+ listeners?.onPointerUp?.(e)+
// If we didn't drag, treat as tap
if (!dragStarted.current && onTap) {
onTap()
}
+
pointerStartPos.current = null
dragStarted.current = false
}
-// Merge our handlers with dnd-kit listeners
const mergedListeners = {
...listeners,
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
}
Suggestion importance[1-10]: 5
__
Why: Removing the incorrect cast and forwarding listeners handlers is safe and improves correctness of event typing, but the claim that overriding onPointerMove/onPointerUp “breaks dragging” is not clearly true for dnd-kit sensors (they primarily rely on onPointerDown and global listeners).
Low
Harden language direction detection
Guard against empty/whitespace language codes to avoid runtime errors when langCode is unexpectedly blank. Normalize common separators (like _) so detection works with both en-US and en_US style codes.
export const isRTL = (langCode: string): boolean => {
- const base = langCode.split("-")[0].toLowerCase()+ const normalized = langCode.trim()+ if (!normalized) return false++ const base = normalized.replaceAll("_", "-").split("-")[0].toLowerCase()
return RTL_LANGUAGES.has(base)
}
Suggestion importance[1-10]: 4
__
Why: Trimming and handling en_US-style language codes makes isRTL() more robust, though the empty-string case doesn’t currently throw (it just returns false). The proposed replaceAll() may be a compatibility concern depending on runtime/target, so a regex replace would be safer.
Low
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Type
Enhancement, Other
Description
Add Babylon bottle fill, slosh, squeeze
Add win/overflow particle celebrations
Implement Zustand progress + persistence
Build DnD phrase assembly with TTS
Diagram Walkthrough
File Walkthrough
18 files
Add 3D bottle, liquid fill, particlesImplement win celebration particle systemsCreate Babylon scene with ortho cameraReact canvas bridge for bottle and particlesAdd Zustand store for gameplay and bottlesWire DnD gameplay UI and bottle triggersAdd phrase navigation, win handling, modalsLoad entries, rotate languages, build blocksAdd lightweight i18n helper for UI stringsAdd multilingual tokenizer with CJK supportRegister game module and dev mock hostMake draggable word blocks with tap-to-speakAdd droppable placement area with RTL supportAdd choices bank droppable/sortable listDisplay score and recent bottle collectionAdd navigation, TTS, answer, fruit toggleRender prompt phrase with language directionAdd level completion modal with next-level hint2 files
Define fruit palettes and level progression constantsConfigure Vite library build and manifest revisioning18 files