Skip to content

JS: different approach to juice squeeze layout#174

Draft
skyl wants to merge 1 commit into
mainfrom
new-squeeze
Draft

JS: different approach to juice squeeze layout#174
skyl wants to merge 1 commit into
mainfrom
new-squeeze

Conversation

@skyl

@skyl skyl commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

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

flowchart LR
  host["Corpan Host API"] 
  loader["`usePhraseLoader` (entry -> blocks)"]
  store["Zustand `useGameStore`"]
  ui["React DnD UI (`App` + components)"]
  win["Win detection + scoring"]
  bottle["Babylon bottle (`createBottle3D`)"]
  particles["Particle systems (win/squeeze/overflow)"]

  host -- "fetch entries / TTS" --> loader
  loader -- "load phrase + blocks" --> store
  store -- "state for rendering" --> ui
  ui -- "placement changes" --> win
  win -- "update bottle progress" --> store
  store -- "fill % + fruit color" --> bottle
  win -- "trigger celebrations" --> particles
Loading

File Walkthrough

Relevant files
Enhancement
18 files
bottle3D.ts
Add 3D bottle, liquid fill, particles                                       
+404/-0 
particles.ts
Implement win celebration particle systems                             
+187/-0 
createScene.ts
Create Babylon scene with ortho camera                                     
+75/-0   
BottleCanvas.tsx
React canvas bridge for bottle and particles                         
+117/-0 
gameState.ts
Add Zustand store for gameplay and bottles                             
+404/-0 
App.tsx
Wire DnD gameplay UI and bottle triggers                                 
+266/-0 
useGameLogic.ts
Add phrase navigation, win handling, modals                           
+207/-0 
usePhraseLoader.ts
Load entries, rotate languages, build blocks                         
+146/-0 
translations.ts
Add lightweight i18n helper for UI strings                             
+124/-0 
tokenizer.ts
Add multilingual tokenizer with CJK support                           
+79/-0   
main.tsx
Register game module and dev mock host                                     
+160/-0 
WordBlock.tsx
Make draggable word blocks with tap-to-speak                         
+96/-0   
PlacementArea.tsx
Add droppable placement area with RTL support                       
+56/-0   
ChoicesBank.tsx
Add choices bank droppable/sortable list                                 
+33/-0   
TopBar.tsx
Display score and recent bottle collection                             
+46/-0   
ControlBar.tsx
Add navigation, TTS, answer, fruit toggle                               
+70/-0   
PromptPhrase.tsx
Render prompt phrase with language direction                         
+27/-0   
LevelComplete.tsx
Add level completion modal with next-level hint                   
+80/-0   
Configuration changes
2 files
colors.ts
Define fruit palettes and level progression constants       
+103/-0 
vite.config.ts
Configure Vite library build and manifest revisioning       
+65/-0   
Additional files
18 files
app.css +1/-0     
app.js +12184/-0
index.html +12/-0   
manifest.json +12/-0   
package.json +31/-0   
dev-corpan.mjs +87/-0   
GameContainer.tsx +23/-0   
VictoryBurst.tsx +67/-0   
AnswerReveal.tsx +40/-0   
PhraseReview.tsx +57/-0   
useTTS.ts +37/-0   
useWinDetection.ts +36/-0   
types.ts +43/-0   
index.css +625/-0 
rtl.ts +20/-0   
vite-env.d.ts +6/-0     
tsconfig.json +22/-0   
tsconfig.node.json +11/-0   

@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Resource Cleanup

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 loop
scene.registerBeforeRender(() => {
  sloshIntensity *= 0.97
  sloshPhase += 0.08

  if (currentFillLevel > 0.01) {
    const liquidHeight = currentFillLevel * maxLiquidHeight
    const baseY = 0.15 + liquidHeight
    const ambientWave = Math.sin(sloshPhase) * 0.02 + Math.sin(sloshPhase * 1.7) * 0.01
    const sloshWave = Math.sin(sloshPhase * 2) * sloshIntensity * 0.15

    liquidCapMesh.position.y = baseY + ambientWave + sloshWave
    liquidCapMesh.rotation.x = Math.PI / 2 + Math.sin(sloshPhase * 0.8) * sloshIntensity * 0.08
    liquidCapMesh.rotation.z = Math.sin(sloshPhase * 0.5) * sloshIntensity * 0.06
  }
})

// Smooth fill animation
let targetFillLevel = 0
let fillAnimating = false

const animateFill = () => {
  if (!fillAnimating) return

  const diff = targetFillLevel - currentFillLevel
  if (Math.abs(diff) < 0.001) {
    currentFillLevel = targetFillLevel
    fillAnimating = false
  } else {
    currentFillLevel += diff * 0.06
  }

  const hasLiquid = currentFillLevel > 0.01
  liquidMesh.isVisible = hasLiquid
  liquidCapMesh.isVisible = hasLiquid

  const scaleY = Math.max(0.001, currentFillLevel)
  liquidMesh.scaling.y = scaleY
  liquidMesh.position.y = 0.15

  const liquidHeight = Math.max(0.01, currentFillLevel * maxLiquidHeight)
  liquidCapMesh.position.y = 0.15 + liquidHeight

  const capScale = currentFillLevel < 0.2 ? 0.8 + currentFillLevel * 1.0 : 1.0
  liquidCapMesh.scaling.x = capScale
  liquidCapMesh.scaling.z = capScale

  if (fillAnimating) {
    requestAnimationFrame(animateFill)
  }
}

return {
  updateFill: (level: number) => {
    targetFillLevel = Math.max(0, Math.min(1, level))
    if (!fillAnimating) {
      fillAnimating = true
      animateFill()
    }
  },

  setColor: (fruitOrLevel: FruitDef | CEFRLevel) => {
    const fruitColors = typeof fruitOrLevel === "string" ? LEVEL_FRUIT_COLORS[fruitOrLevel] : fruitOrLevel
    currentColor = hexToColor3(fruitColors.primary)

    liquidMaterial.diffuseColor = currentColor
    liquidMaterial.emissiveColor = currentColor.scale(0.35)
    if (liquidMaterial.emissiveFresnelParameters) {
      liquidMaterial.emissiveFresnelParameters.leftColor = currentColor.scale(0.5)
    }

    // Update all particle systems with new color
    const allParticles = [squeezeParticles, overflowParticles, splashParticles]
    allParticles.forEach((ps) => {
      ps.color1 = new Color4(currentColor.r, currentColor.g, currentColor.b, 1)
      ps.color2 = new Color4(currentColor.r * 1.2, currentColor.g * 1.2, currentColor.b, 1)
      ps.colorDead = new Color4(currentColor.r, currentColor.g, currentColor.b, 0)
    })
  },

  triggerSqueeze: () => {
    // Juice spray burst
    squeezeParticles.emitRate = 400
    setTimeout(() => { squeezeParticles.emitRate = 150 }, 200)
    setTimeout(() => { squeezeParticles.emitRate = 0 }, 500)

    sloshIntensity = 1.2

    // Internal splash
    const liquidHeight = currentFillLevel * maxLiquidHeight
    const splashY = 0.15 + liquidHeight + originalLayoutY
    splashParticles.emitter = new Vector3(0, splashY * (originalLayoutScale?.y || 1), 5)

    setTimeout(() => {
      splashParticles.emitRate = 250
      setTimeout(() => { splashParticles.emitRate = 0 }, 350)
    }, 150)
  },

  triggerOverflow: () => {
    // Big juice explosion!
    overflowParticles.emitRate = 500
    setTimeout(() => { overflowParticles.emitRate = 200 }, 400)
    setTimeout(() => { overflowParticles.emitRate = 0 }, 1200)

    sloshIntensity = 1.5
  },

  reset: () => {
    currentFillLevel = 0
    targetFillLevel = 0
    fillAnimating = false

    liquidMesh.scaling.y = 0.001
    liquidMesh.position.y = 0.15
    liquidMesh.isVisible = false
    liquidCapMesh.position.y = 0.15
    liquidCapMesh.scaling.x = 0.8
    liquidCapMesh.scaling.z = 0.8
    liquidCapMesh.rotation.x = Math.PI / 2
    liquidCapMesh.rotation.z = 0
    liquidCapMesh.isVisible = false

    if (glassMaterial) {
      glassMaterial.alpha = 0.25
    }

    sloshIntensity = 0

    bottleContainer.scaling = originalLayoutScale.clone()
    bottleContainer.position.x = 0
    bottleContainer.position.y = originalLayoutY
  },

  updateLayout: (_worldWidth: number, worldHeight: number) => {
    const targetHeight = worldHeight * 1.2
    const bottleNaturalHeight = 7.4
    const scale = targetHeight / bottleNaturalHeight

    originalLayoutScale = new Vector3(scale, scale, scale)
    originalLayoutY = -worldHeight * 0.2

    bottleContainer.scaling = originalLayoutScale.clone()
    bottleContainer.position.y = originalLayoutY
  },

  dispose: () => {
    squeezeParticles.dispose()
    overflowParticles.dispose()
    splashParticles.dispose()
    particleTexture.dispose()
    liquidCapMesh.dispose()
    liquidMesh.dispose()
    bottleMesh.dispose()
    bottleContainer.dispose()
  },
Storage Growth

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.

// Collected bottle data
export type CollectedBottle = {
  id: string
  level: CEFRLevel
  color: string
  gradient?: [string, string, string]
  completedAt: number
  phrases: CompletedPhrase[]
}

// Bottle progress tracking
export type BottleProgress = {
  currentLevel: CEFRLevel
  phrasesInCurrentBottle: number
  bottlesCompletedThisLevel: number
  bottleCollection: CollectedBottle[]
  currentColorIndex: number
  currentBottlePhrases: CompletedPhrase[]
}

// Current phrase data
export type PhraseData = {
  id: string | null
  targetText: string | null
  blockText: string | null
  targetLang: string | null
  blockLang: string | null
  correctWords: string[]
}

// Game statistics
export type GameStats = {
  score: number
  allTimeScore: number
  completedPhrases: number
  allTimeCompletedPhrases: number
  currentStreak: number
  bestStreak: number
  totalPhrases: number
  completedPhraseIds: string[]
}

// Game settings
export type GameSettings = {
  ttsEnabled: boolean
  soundEffectsEnabled: boolean
  fruitsEnabled: boolean
}

// Game state
export type GameState = {
  // Current phrase data
  phrase: PhraseData

  // Word blocks with zone tracking
  blocks: WordBlock[]

  // Placement order (block IDs in order they appear in placement area)
  placementOrder: string[]

  // Game status
  hasWon: boolean
  isLoading: boolean

  // Statistics
  stats: GameStats

  // Settings
  settings: GameSettings

  // Bottle progress
  bottleProgress: BottleProgress

  // Actions
  loadPhrase: (phrase: Omit<PhraseData, "correctWords"> & { correctWords: string[] }, blocks: WordBlock[]) => void
  moveBlockToPlacement: (blockId: string, insertIndex?: number) => void
  moveBlockToChoices: (blockId: string) => void
  reorderPlacement: (activeId: string, overId: string) => void
  checkWin: () => boolean
  setWon: (won: boolean) => void
  recordWin: (wordCount: number, phraseDetails?: { targetText: string; blockText: string; targetLang: string; blockLang: string }, fruitGradient?: [string, string, string]) => void
  toggleFruits: () => void
  updateSettings: (settings: Partial<GameSettings>) => void
  resetBlocks: () => void
  setLevel: (level: CEFRLevel) => void
  setColorIndex: (index: number) => void
  getBottleFillPercent: () => number
  isLevelComplete: () => boolean
  getCurrentFruit: () => FruitDef
  advanceColorIndex: () => void
}

const initialBottleProgress: BottleProgress = {
  currentLevel: "A0",
  phrasesInCurrentBottle: 0,
  bottlesCompletedThisLevel: 0,
  bottleCollection: [],
  currentColorIndex: 0,
  currentBottlePhrases: [],
}

const initialState = {
  phrase: {
    id: null,
    targetText: null,
    blockText: null,
    targetLang: null,
    blockLang: null,
    correctWords: [],
  } as PhraseData,
  blocks: [] as WordBlock[],
  placementOrder: [] as string[],
  hasWon: false,
  isLoading: false,
  stats: {
    score: 0,
    allTimeScore: 0,
    completedPhrases: 0,
    allTimeCompletedPhrases: 0,
    currentStreak: 0,
    bestStreak: 0,
    totalPhrases: 0,
    completedPhraseIds: [],
  } as GameStats,
  settings: {
    ttsEnabled: true,
    soundEffectsEnabled: true,
    fruitsEnabled: false,
  } as GameSettings,
  bottleProgress: initialBottleProgress,
}

export const useGameStore = create<GameState>()(
  persist(
    (set, get) => ({
      ...initialState,

      loadPhrase: (phrase, blocks) => {
        set({
          phrase: { ...phrase, correctWords: [...phrase.correctWords] },
          blocks,
          placementOrder: [],
          hasWon: false,
          isLoading: false,
        })
      },

      moveBlockToPlacement: (blockId, insertIndex) => {
        set((state) => {
          const blockIndex = state.blocks.findIndex(b => b.id === blockId)
          if (blockIndex === -1) return state

          const block = state.blocks[blockIndex]
          if (block.zone === "placement") return state

          const newBlocks = [...state.blocks]
          newBlocks[blockIndex] = { ...block, zone: "placement" }

          const newPlacementOrder = [...state.placementOrder]
          if (insertIndex !== undefined && insertIndex >= 0) {
            newPlacementOrder.splice(insertIndex, 0, blockId)
          } else {
            newPlacementOrder.push(blockId)
          }

          return { blocks: newBlocks, placementOrder: newPlacementOrder }
        })
      },

      moveBlockToChoices: (blockId) => {
        set((state) => {
          const blockIndex = state.blocks.findIndex(b => b.id === blockId)
          if (blockIndex === -1) return state

          const block = state.blocks[blockIndex]
          if (block.zone === "choices") return state

          const newBlocks = [...state.blocks]
          newBlocks[blockIndex] = { ...block, zone: "choices" }

          const newPlacementOrder = state.placementOrder.filter(id => id !== blockId)

          return { blocks: newBlocks, placementOrder: newPlacementOrder }
        })
      },

      reorderPlacement: (activeId, overId) => {
        set((state) => {
          const oldIndex = state.placementOrder.indexOf(activeId)
          const newIndex = state.placementOrder.indexOf(overId)
          if (oldIndex === -1 || newIndex === -1) return state

          const newOrder = [...state.placementOrder]
          newOrder.splice(oldIndex, 1)
          newOrder.splice(newIndex, 0, activeId)

          return { placementOrder: newOrder }
        })
      },

      checkWin: () => {
        const state = get()
        const { phrase, blocks, placementOrder } = state

        if (phrase.correctWords.length === 0) return false

        // Get words in placement order
        const placedWords = placementOrder
          .map(id => blocks.find(b => b.id === id))
          .filter((b): b is WordBlock => b !== undefined)
          .map(b => b.word)

        if (placedWords.length !== phrase.correctWords.length) return false

        return placedWords.every((word, i) => word === phrase.correctWords[i])
      },

      setWon: (won) => {
        set({ hasWon: won })
      },

      recordWin: (wordCount, phraseDetails, fruitGradient) => {
        set((state) => {
          const points = wordCount
          const phraseId = state.phrase.id || `phrase-${Date.now()}`
          const existingIds = state.stats.completedPhraseIds || []

          const bp = state.bottleProgress || initialBottleProgress
          const newPhrasesInBottle = bp.phrasesInCurrentBottle + 1
          const bottleComplete = newPhrasesInBottle >= 10

          const completedPhrase: CompletedPhrase | null = phraseDetails ? {
            id: phraseId,
            targetText: phraseDetails.targetText,
            blockText: phraseDetails.blockText,
            targetLang: phraseDetails.targetLang,
            blockLang: phraseDetails.blockLang,
            completedAt: Date.now(),
          } : null

          const existingPhrases = bp.currentBottlePhrases || []
          const newCurrentBottlePhrases = completedPhrase
            ? [...existingPhrases, completedPhrase]
            : existingPhrases

          let newBottleProgress: BottleProgress
          if (bottleComplete) {
            const allFruits = getAllFruits()
            const currentFruit = allFruits[bp.currentColorIndex % allFruits.length]
            const newBottle: CollectedBottle = {
              id: `bottle-${Date.now()}`,
              level: currentFruit.level,
              color: currentFruit.primary,
              gradient: fruitGradient || currentFruit.gradient,
              completedAt: Date.now(),
              phrases: newCurrentBottlePhrases,
            }
            newBottleProgress = {
              ...bp,
              phrasesInCurrentBottle: 0,
              bottlesCompletedThisLevel: Math.min(bp.bottlesCompletedThisLevel + 1, 99),
              bottleCollection: [...bp.bottleCollection, newBottle],
              currentBottlePhrases: [],
              currentColorIndex: (bp.currentColorIndex + 1) % allFruits.length,
            }
          } else {
            newBottleProgress = {
              ...bp,
              phrasesInCurrentBottle: newPhrasesInBottle,
              currentBottlePhrases: newCurrentBottlePhrases,
            }
          }

          const nextStreak = state.stats.currentStreak + 1
          const nextBestStreak = Math.max(state.stats.bestStreak, nextStreak)

          return {
            stats: {
              ...state.stats,
              score: state.stats.score + points,
              allTimeScore: (state.stats.allTimeScore || 0) + points,
              completedPhrases: state.stats.completedPhrases + 1,
              allTimeCompletedPhrases: (state.stats.allTimeCompletedPhrases || 0) + 1,
              currentStreak: nextStreak,
              bestStreak: nextBestStreak,
              totalPhrases: state.stats.totalPhrases + 1,
              completedPhraseIds: [...existingIds, phraseId],
            },
            bottleProgress: newBottleProgress,
          }
        })
      },

      toggleFruits: () => {
        set((state) => ({
          settings: { ...state.settings, fruitsEnabled: !state.settings.fruitsEnabled },
        }))
      },

      updateSettings: (newSettings) => {
        set((state) => ({
          settings: { ...state.settings, ...newSettings },
        }))
      },

      resetBlocks: () => {
        set((state) => ({
          blocks: state.blocks.map(b => ({ ...b, zone: "choices" as const })),
          placementOrder: [],
          hasWon: false,
        }))
      },

      setLevel: (level) => {
        set((state) => ({
          bottleProgress: {
            ...state.bottleProgress,
            currentLevel: level,
            phrasesInCurrentBottle: 0,
            bottlesCompletedThisLevel: 0,
          },
        }))
      },

      setColorIndex: (index) => {
        set((state) => ({
          bottleProgress: { ...state.bottleProgress, currentColorIndex: index },
        }))
      },

      getBottleFillPercent: () => {
        const state = get()
        const bp = state.bottleProgress || initialBottleProgress
        return (bp.phrasesInCurrentBottle / 10) * 100
      },

      isLevelComplete: () => {
        const state = get()
        const bp = state.bottleProgress || initialBottleProgress
        const bottlesNeeded = BOTTLES_PER_LEVEL[bp.currentLevel]
        return bp.bottlesCompletedThisLevel >= bottlesNeeded
      },

      getCurrentFruit: () => {
        const state = get()
        const bp = state.bottleProgress || initialBottleProgress
        const allFruits = getAllFruits()
        return allFruits[bp.currentColorIndex % allFruits.length]
      },

      advanceColorIndex: () => {
        set((state) => {
          const allFruits = getAllFruits()
          return {
            bottleProgress: {
              ...state.bottleProgress,
              currentColorIndex: (state.bottleProgress.currentColorIndex + 1) % allFruits.length,
            },
          }
        })
      },
    }),
    {
      name: "juice-squeeze2-game-state",
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        stats: {
          allTimeScore: state.stats.allTimeScore,
          allTimeCompletedPhrases: state.stats.allTimeCompletedPhrases,
          bestStreak: state.stats.bestStreak,
          totalPhrases: state.stats.totalPhrases,
          completedPhraseIds: state.stats.completedPhraseIds,
        },
        settings: state.settings,
        bottleProgress: state.bottleProgress,
      }),
    }
Win Duplication

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 handler
const handleWin = useCallback(() => {
  playSuccessSound()

  const wordCount = phrase.correctWords.length
  const fruit = getCurrentFruit()

  // Check bottle state before recording win
  const bottleProgress = useGameStore.getState().bottleProgress
  const wasLevelComplete = isLevelComplete()
  const phrasesBeforeWin = bottleProgress.phrasesInCurrentBottle

  recordWin(
    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 phrase
  if (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(() => {
    const bottleJustCompleted = phrasesBeforeWin === 9
    const isNowLevelComplete = isLevelComplete()
    if (bottleJustCompleted && isNowLevelComplete && !wasLevelComplete) {
      setShowLevelComplete(true)
    }
  }, 100)
}, [phrase, recordWin, getCurrentFruit, speakWithDelay, isLevelComplete, playSuccessSound])

useWinDetection({ onWin: handleWin })

@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Make particle emitters follow bottle

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.

corpan/packs/juice-squeeze2/src/babylon/bottle3D.ts [180-338]

-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).

corpan/packs/juice-squeeze2/src/babylon/particles.ts [151-185]

+const timeoutIds: number[] = []
+
+const clearTimers = () => {
+  while (timeoutIds.length) {
+    const id = timeoutIds.pop()
+    if (id !== undefined) window.clearTimeout(id)
+  }
+}
+
+const schedule = (fn: () => void, ms: number) => {
+  timeoutIds.push(window.setTimeout(fn, ms))
+}
+
 return {
   trigger: () => {
+    clearTimers()
+
     // Main fountain burst
     mainBurst.emitRate = 600
-    setTimeout(() => { mainBurst.emitRate = 300 }, 300)
-    setTimeout(() => { mainBurst.emitRate = 0 }, 1000)
+    schedule(() => { mainBurst.emitRate = 300 }, 300)
+    schedule(() => { mainBurst.emitRate = 0 }, 1000)
 
     // Side sprays with slight delay
-    setTimeout(() => {
+    schedule(() => {
       leftSpray.emitRate = 250
       rightSpray.emitRate = 250
     }, 100)
-    setTimeout(() => {
+    schedule(() => {
       leftSpray.emitRate = 0
       rightSpray.emitRate = 0
     }, 800)
 
     // Dripping effect
-    setTimeout(() => {
-      drips.emitRate = 250
-    }, 300)
-    setTimeout(() => {
-      drips.emitRate = 0
-    }, 1500)
+    schedule(() => { drips.emitRate = 250 }, 300)
+    schedule(() => { drips.emitRate = 0 }, 1500)
   },
 
   setColor,
 
   dispose: () => {
+    clearTimers()
     mainBurst.dispose()
     leftSpray.dispose()
     rightSpray.dispose()
     drips.dispose()
     particleTexture.dispose()
   },
 }
Suggestion importance[1-10]: 7

__

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.

corpan/packs/juice-squeeze2/scripts/dev-corpan.mjs [49-87]

+let buildWatcher
+let server
+let shuttingDown = false
+
+const shutdown = (code = 0) => {
+  if (shuttingDown) return
+  shuttingDown = true
+  try {
+    buildWatcher?.kill()
+  } catch {}
+  try {
+    server?.kill()
+  } catch {}
+  process.exit(code)
+}
+
 const run = (cmd, args, cwd, name) => {
   const child = spawn(cmd, args, { cwd, stdio: "inherit" })
   child.on("exit", (code) => {
     if (code && code !== 0) {
       console.error(`[juice-squeeze2] ${name} exited with ${code}`)
     }
-    process.exit(code ?? 0)
+    shutdown(code ?? 0)
   })
   return child
 }
 
-const buildWatcher = run(
+buildWatcher = run(
   npmCmd,
   ["run", "build", "--", "--watch"],
   packRoot,
   "build:watch"
 )
 
-const server = run(
-  "python3",
+const pythonCmd = isWin ? "python" : "python3"
+server = run(
+  pythonCmd,
   ["-m", "http.server", "8989", "--bind", "0.0.0.0"],
   packsRoot,
   "server"
 )
 
 watchDist()
 scheduleManifestUpdate()
 
 console.log("[juice-squeeze2] Dev server running at http://localhost:8989/juice-squeeze2/")
 console.log("[juice-squeeze2] Build watcher started")
 
-const shutdown = () => {
-  buildWatcher.kill("SIGINT")
-  server.kill("SIGINT")
-  process.exit(0)
-}
+process.on("SIGINT", () => shutdown(0))
+process.on("SIGTERM", () => shutdown(0))
 
-process.on("SIGINT", shutdown)
-process.on("SIGTERM", shutdown)
-
Suggestion importance[1-10]: 7

__

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).

corpan/packs/juice-squeeze2/src/components/WordBlock.tsx [34-69]

 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.

corpan/packs/juice-squeeze2/src/utils/rtl.ts [10-13]

 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant