threlte logo

Arcade machine

The Arcade machine was introduced during a previous major revision of threlte. It involes sounds, global state, custom rendering and basic scene transitions. Give it a whirl, copy parts, remix it - have fun 😄

<script lang="ts">
  import { Canvas, extend } from '@threlte/core'
  import { useProgress } from '@threlte/extras'
  import { World } from '@threlte/rapier'
  import { CustomGridHelper } from './game/objects/CustomGridHelper'
  import { game } from './game/Game.svelte'
  import Scene from './Scene.svelte'
  import { WebGLRenderer } from 'three'

  const { progress, finishedOnce } = useProgress()

  $effect(() => {
    game.sound.handleMuted(game.muted)
  })

  extend({
    CustomGridHelper
  })
</script>

<div class="absolute h-full w-full overflow-hidden">
  <div
    class="absolute h-full w-full transition-all delay-500 duration-1000"
    class:opacity-0={!$finishedOnce}
  >
    <Canvas
      createRenderer={(canvas: HTMLCanvasElement) => {
        return new WebGLRenderer({
          canvas,
          powerPreference: 'high-performance',
          antialias: false,
          stencil: false,
          depth: false
        })
      }}
    >
      <World gravity={[0, 0, 0]}>
        <Scene />
      </World>
    </Canvas>
  </div>
  {#if !$finishedOnce}
    <div
      class="pointer-events-none absolute left-0 top-0 flex h-full w-full flex-row items-center justify-center p-12 text-2xl text-white"
    >
      {($progress * 100).toFixed()} %
    </div>
  {:else if game.state === 'off'}
    <div
      class="pointer-events-none absolute left-0 top-0 flex h-full w-full flex-row items-center justify-center p-12"
    >
      <button
        onclick={() => {
          game.sound.resume()
          game.state = 'intro'
        }}
        class="pointer-events-auto rounded-full bg-white px-8 py-4 text-2xl text-black"
      >
        Insert Coin
      </button>
    </div>
  {/if}

  <div class="absolute right-6 top-6">
    <button
      class="rounded-full bg-white p-2 [&>*]:h-7 [&>*]:w-7"
      onclick={() => (game.muted = !game.muted)}
    >
      {#if game.muted}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="192"
          height="192"
          fill="#000000"
          viewBox="0 0 256 256"
        >
          <rect
            width="256"
            height="256"
            fill="none"
          /><path
            d="M80,168H32a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8H80l72-56V224Z"
            fill="none"
            stroke="#000000"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          />
          <line
            x1="240"
            y1="104"
            x2="192"
            y2="152"
            fill="none"
            stroke="#000000"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          />
          <line
            x1="240"
            y1="152"
            x2="192"
            y2="104"
            fill="none"
            stroke="#000000"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          />
        </svg>
      {:else}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="192"
          height="192"
          fill="#000000"
          viewBox="0 0 256 256"
          ><rect
            width="256"
            height="256"
            fill="none"
          /><path
            d="M80,168H32a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8H80l72-56V224Z"
            fill="none"
            stroke="#000000"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          /><line
            x1="192"
            y1="104"
            x2="192"
            y2="152"
            fill="none"
            stroke="#000000"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          /><line
            x1="224"
            y1="88"
            x2="224"
            y2="168"
            fill="none"
            stroke="#000000"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          /></svg
        >
      {/if}
    </button>
  </div>
</div>
<script lang="ts">
  import { useTask, useThrelte } from '@threlte/core'
  import {
    BloomEffect,
    BrightnessContrastEffect,
    ChromaticAberrationEffect,
    EffectComposer,
    EffectPass,
    KernelSize,
    RenderPass,
    SMAAEffect,
    SMAAPreset
  } from 'postprocessing'
  import { onMount } from 'svelte'
  import { Tween } from 'svelte/motion'
  import { Vector2 } from 'three'
  import { game } from './game/Game.svelte'

  const { camera, renderer, autoRender, renderStage } = useThrelte()

  let bloomEffect: BloomEffect | undefined = undefined

  let machineIsOff = $derived(game.state === 'off' ? true : false)

  const bloomIntensity = new Tween(0, {
    duration: 3e3
  })

  $effect(() => {
    bloomIntensity.set(machineIsOff ? 0 : 1)
  })
  $effect(() => {
    if (bloomEffect) bloomEffect.intensity = bloomIntensity.current
  })
  $effect(() => {
    if ($camera && game.arcadeMachineScene) {
      addComposerAndPasses()
    }
  })

  const composer = new EffectComposer(renderer)

  const addComposerAndPasses = () => {
    composer.removeAllPasses()

    composer.addPass(new RenderPass(game.arcadeMachineScene, $camera))
    bloomEffect = new BloomEffect({
      intensity: bloomIntensity.current,
      luminanceThreshold: 0.15,
      height: 512,
      width: 512,
      luminanceSmoothing: 0.08,
      mipmapBlur: true,
      kernelSize: KernelSize.MEDIUM
    })
    bloomEffect.luminancePass.enabled = true
    ;(bloomEffect as any).ignoreBackground = true
    composer.addPass(new EffectPass($camera, bloomEffect))
    composer.addPass(
      new EffectPass(
        $camera,
        new ChromaticAberrationEffect({
          offset: new Vector2(0.0005, 0.0005),
          modulationOffset: 0,
          radialModulation: false
        })
      )
    )
    composer.addPass(
      new EffectPass(
        $camera,
        new BrightnessContrastEffect({
          brightness: 0,
          contrast: 0.1
        })
      )
    )
    composer.addPass(
      new EffectPass(
        $camera,
        new SMAAEffect({
          preset: SMAAPreset.LOW
        })
      )
    )
  }

  // When using PostProcessing, we need to disable autoRender
  onMount(() => {
    let before = autoRender.current
    autoRender.set(false)
    return () => {
      autoRender.set(before)
      composer.removeAllPasses()
    }
  })

  useTask(
    (delta) => {
      composer.render(delta)
    },
    { stage: renderStage }
  )
</script>
<script lang="ts">
  import { interactivity } from '@threlte/extras'
  import { Debug } from '@threlte/rapier'
  import { onDestroy } from 'svelte'
  import ArcadeScene from './arcade/Scene.svelte'
  import GameScene from './game/Scene.svelte'
  import { game } from './game/Game.svelte'
  import CustomRendering from './Renderer.svelte'

  const intervalHandler = setInterval(() => {
    game.blinkClock = game.blinkClock === 0 ? 1 : 0
  }, 96)

  onDestroy(() => {
    clearInterval(intervalHandler)
  })

  interactivity()
</script>

{#if game.debug}
  <Debug />
{:else}
  <CustomRendering />
{/if}

<ArcadeScene />

<GameScene />
<script lang="ts">
  import { T } from '@threlte/core'
  import { Tween } from 'svelte/motion'
  import type { Color } from 'three'

  type Props = {
    lightColor: Color
    machineIsOff?: boolean
    pointLightsOff?: boolean
  }

  let { machineIsOff = false, pointLightsOff = false, lightColor }: Props = $props()

  let pointLightIntensity = new Tween(0)

  const blueLightIntensity = new Tween(2, {
    duration: 3e3
  })

  const redLightIntensity = new Tween(1, {
    duration: 3e3
  })

  const whiteLightIntensity = new Tween(0, {
    duration: 3e3
  })

  const whiteAmbientLightIntensity = new Tween(1, {
    duration: 3e3
  })

  $effect(() => {
    if (pointLightsOff) {
      pointLightIntensity.set(1)
    } else {
      pointLightIntensity.set(0)
    }
  })

  $effect(() => {
    setTimeout(() => {
      pointLightIntensity.set(1, {
        duration: 200
      })
    }, 1000)
  })

  $effect(() => {
    blueLightIntensity.set(machineIsOff ? 2 : 2)
    redLightIntensity.set(machineIsOff ? 2 : 2)
    whiteLightIntensity.set(machineIsOff ? 0 : 0)
    whiteAmbientLightIntensity.set(machineIsOff ? 1 : 0)
  })
</script>

<!-- This PointLight replicates the light emitted by the screen -->
<T.PointLight
  args={['black']}
  position.y={1.376583185239323}
  position.z={-0.12185962320246482}
  intensity={25 * pointLightIntensity.current}
  distance={1.2}
  decay={2}
  color={lightColor}
/>

<T.AmbientLight
  intensity={8}
  color={lightColor}
/>
<T.AmbientLight
  intensity={whiteAmbientLightIntensity.current}
  color={'white'}
/>

<!-- Red light -->
<T.DirectionalLight
  intensity={redLightIntensity.current}
  color="#F67F55"
  position.x={-2.2}
  position.y={3.5}
  position.z={2.6}
/>

<!-- Blue light -->
<T.DirectionalLight
  intensity={blueLightIntensity.current}
  position.x={2.2}
  position.y={3.4}
  position.z={2.6}
  color="#2722F3"
/>

<!-- White light -->
<T.DirectionalLight
  intensity={whiteLightIntensity.current}
  position.x={-1}
  position.y={2.5}
  position.z={1}
  color="white"
/>
<script lang="ts">
  import { T } from '@threlte/core'
  import { useGltf, useTexture, useCursor } from '@threlte/extras'
  import type { Mesh, MeshStandardMaterial, Texture } from 'three'
  import { MathUtils } from 'three'
  import { Tween } from 'svelte/motion'
  import { StickPosition, Button } from './types'

  type GLTFResult = {
    nodes: {
      BodyMesh: Mesh
      LeftCover: Mesh
      RightCover: Mesh
      ScreenFrame: Mesh
      joystick_base: Mesh
      joystick_stick_application: Mesh
      joystick_stick: Mesh
      joystick_cap: Mesh
      Main_Button_Enclosure: Mesh
      Main_Button: Mesh
      Screen: Mesh
    }
    materials: {
      ['machine body main']: MeshStandardMaterial
      ['machine body outer']: MeshStandardMaterial
      ['screen frame']: MeshStandardMaterial
      ['joystick base']: MeshStandardMaterial
      ['joystick stick']: MeshStandardMaterial
      ['joystick cap']: MeshStandardMaterial
      Screen: MeshStandardMaterial
    }
  }

  type Props = {
    joystick?: StickPosition
    button?: Button
    screenTexture?: Texture | undefined
    screenClicked?: () => void
  }

  let {
    joystick = StickPosition.Idle,
    button = Button.Idle,
    screenTexture,
    screenClicked
  }: Props = $props()

  const { onPointerEnter, onPointerLeave } = useCursor('pointer')

  const gltf = useGltf<GLTFResult>('/models/ball-game/archade-machine/arcade_machine_own.glb').then(
    (gltf) => {
      Object.entries(gltf.materials).forEach(([name, material]) => {
        const n = name as keyof typeof gltf.materials
        if (n === 'joystick cap') material.envMapIntensity = 1
        else if (n === 'joystick stick') material.envMapIntensity = 1
        else material.envMapIntensity = 0.2
      })
      return gltf
    }
  )
  const scanLinesTexture = useTexture('/models/ball-game/archade-machine/textures/scanlines.png')

  const stickRotation = new Tween(0, {
    duration: 100
  })

  $effect(() => {
    if (joystick == StickPosition.Left) {
      stickRotation.set(-15 * MathUtils.DEG2RAD)
    } else if (joystick == StickPosition.Right) {
      stickRotation.set(15 * MathUtils.DEG2RAD)
    } else {
      stickRotation.set(0)
    }
  })
</script>

{#await gltf then model}
  <!-- Generated by gltfjsx -->
  <T.Group rotation.y={MathUtils.DEG2RAD * 180}>
    <!-- The Main Body -->
    <T.Mesh
      geometry={model.nodes.BodyMesh.geometry}
      material={model.materials['machine body main']}
      position={[0.2755, 0, 0]}
    />
    <T.Mesh
      geometry={model.nodes.LeftCover.geometry}
      material={model.materials['machine body outer']}
      position={[0.3, 1.2099, -0.1307]}
    />
    <T.Mesh
      geometry={model.nodes.RightCover.geometry}
      material={model.materials['machine body outer']}
      position={[-0.3, 1.2099, -0.1307]}
      scale={[-1, 1, 1]}
    />
    <T.Mesh
      geometry={model.nodes.ScreenFrame.geometry}
      material={model.materials['screen frame']}
      position={[0.2755, 0.0633, 0.0346]}
    />

    <!-- Joystick -->
    <T.Mesh
      geometry={model.nodes.joystick_base.geometry}
      material={model.materials['joystick base']}
      position={[0.1336, 0.9611, -0.1976]}
      rotation={[-0.1939, 0, 0]}
    />
    <T.Mesh
      geometry={model.nodes.joystick_stick_application.geometry}
      material={model.materials['joystick base']}
      position={[0.1336, 0.9653, -0.1984]}
      rotation={[-0.1939, 0, stickRotation.current]}
    >
      <T.Mesh
        geometry={model.nodes.joystick_stick.geometry}
        material={model.materials['joystick stick']}
        position={[0, -0.0145, 0.0001]}
      >
        <T.Mesh
          geometry={model.nodes.joystick_cap.geometry}
          material={model.materials['joystick cap']}
          position={[-0.0001, 0.1126, -0.0005]}
          material.envMapIntensity={0.5}
        />
      </T.Mesh>
    </T.Mesh>

    <!-- The Button -->
    <T.Mesh
      geometry={model.nodes.Main_Button_Enclosure.geometry}
      material={model.materials['joystick base']}
      position={[-0.1143, 0.9795, -0.0933]}
      rotation={[-0.1801, 0, 0]}
      scale={0.9409}
    >
      <T.Mesh
        geometry={model.nodes.Main_Button.geometry}
        material={model.materials['joystick cap']}
        position={[0.0001, 0.007 + (button == Button.Pressed ? -0.003 : 0), -0.0003]}
        rotation={[0.192, 0, 0]}
        scale={0.724}
      />
    </T.Mesh>

    <!-- The screen itself gets a special treatment -->
    <T.Mesh
      geometry={model.nodes.Screen.geometry}
      position={[0, 1.3774, 0.1447]}
      scale={1.0055}
      onpointerenter={onPointerEnter}
      onpointerleave={onPointerLeave}
      onclick={() => {
        screenClicked?.()
      }}
    >
      {#await scanLinesTexture then texture}
        {#if screenTexture}
          <T.MeshStandardMaterial
            metalness={0.9}
            roughness={0.2}
            map={screenTexture}
            metalnessMap={texture}
          />
        {:else}
          <T.MeshStandardMaterial
            metalness={0.9}
            roughness={0.2}
            color="#141414"
            metalnessMap={texture}
          />
        {/if}
      {/await}
    </T.Mesh>
  </T.Group>
{/await}
<script lang="ts">
  import { T, useTask, useThrelte } from '@threlte/core'
  import { useInteractivity, OrbitControls } from '@threlte/extras'
  import { cubicInOut } from 'svelte/easing'
  import { Spring, Tween } from 'svelte/motion'
  import { Color, Object3D, PerspectiveCamera, Scene } from 'three'
  import { game } from '../game/Game.svelte'
  import Lights from './Lights.svelte'
  import Machine from './Machine.svelte'
  import { Button, StickPosition } from './types'

  const { scene } = useThrelte()

  let leftPressed = $state(false)
  let rightPressed = $state(false)
  let spacePressed = $state(false)

  let joystick = $derived.by(() => {
    if (leftPressed && !rightPressed) {
      return StickPosition.Left
    } else if (!leftPressed && rightPressed) {
      return StickPosition.Right
    } else {
      return StickPosition.Idle
    }
  })
  let button = $derived.by(() => {
    if (spacePressed) {
      return Button.Pressed
    } else {
      return Button.Idle
    }
  })

  const machineIsOff = $derived(game.state == 'off' ? true : false)

  const cameraTweenZ = new Tween(2.1, {
    duration: 3e3,
    easing: cubicInOut
  })

  const { pointer } = useInteractivity()

  let screenFocused = $state(false)

  const screenPos = {
    x: 0,
    y: 1.3774,
    z: 0.1447
  }

  const cameraTargetPos = new Spring(
    {
      x: $pointer.x * 0.1,
      y: 1.23,
      z: 0
    },
    {
      precision: 0.000001
    }
  )

  const cameraPos = new Spring(
    {
      x: $pointer.x * 0.1, //(machineIsOff ? 2 : 0.1),
      y: 1.48,
      z: cameraTweenZ.current
    },
    {
      stiffness: 0.05,
      damping: 0.9,
      precision: 0.00001
    }
  )

  let cameraTarget: Object3D | undefined = undefined
  let camera: PerspectiveCamera | undefined = undefined

  const backgroundColor = new Tween(new Color('#020203'), {
    duration: 2.5e3
  })

  useTask(() => {
    if (!camera || !cameraTarget) return
    camera.lookAt(cameraTarget.position)
  })

  const onScreenClick = () => {
    screenFocused = !screenFocused
  }

  const onKeyUp = (e: KeyboardEvent) => {
    if (e.key === 'ArrowLeft') {
      e.preventDefault()
      leftPressed = false
    } else if (e.key === 'ArrowRight') {
      e.preventDefault()
      rightPressed = false
    } else if (e.key === ' ') {
      spacePressed = false
    }
  }

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== ' ') return
    if (e.key === 'ArrowLeft') {
      e.preventDefault()
      leftPressed = true
    } else if (e.key === 'ArrowRight') {
      e.preventDefault()
      rightPressed = true
    } else if (e.key === ' ') {
      spacePressed = true
    }
  }

  $effect(() => {
    cameraTargetPos.set(
      screenFocused
        ? {
            ...screenPos,
            z: -screenPos.z
          }
        : {
            x: $pointer.x * 0.1,
            y: 1.23,
            z: 0
          }
    )
  })

  $effect(() => {
    cameraPos.set(
      screenFocused
        ? {
            x: screenPos.x,
            y: screenPos.y + 0.15,
            z: screenPos.z + 0.5
          }
        : {
            x: $pointer.x * (machineIsOff ? 0.1 : 0.1),
            y: 1.48,
            z: cameraTweenZ.current
          }
    )
  })

  $effect(() => {
    cameraTweenZ.set(machineIsOff ? 2.1 : 1.4)
    backgroundColor.set(machineIsOff ? new Color('#020203') : new Color('#020203'))
  })

  $effect(() => {
    scene.background = new Color(backgroundColor.current)
  })
</script>

<svelte:window
  on:keydown={onKeyDown}
  on:keyup={onKeyUp}
/>

<T.Scene
  oncreate={(ref: Scene) => {
    game.arcadeMachineScene = ref
  }}
  background={new Color(0x020203)}
>
  <!-- The camera target -->
  <T.Object3D
    oncreate={(ref: Object3D) => {
      cameraTarget = ref
    }}
    position.x={cameraTargetPos.current.x}
    position.y={cameraTargetPos.current.y}
    position.z={cameraTargetPos.current.z}
  />

  {#if game.orbitControls}
    <T.PerspectiveCamera
      position.x={20}
      position.y={20}
      position.z={20}
      fov={60}
      makeDefault
    >
      <OrbitControls />
    </T.PerspectiveCamera>
  {:else}
    <T.PerspectiveCamera
      position.x={cameraPos.current.x}
      position.y={cameraPos.current.y}
      position.z={cameraPos.current.z}
      fov={30}
      makeDefault
      oncreate={(ref: PerspectiveCamera) => {
        camera = ref
      }}
    />
  {/if}

  <Machine
    screenClicked={onScreenClick}
    screenTexture={game.gameTexture}
    {joystick}
    {button}
  />

  <Lights
    lightColor={game.averageScreenColor}
    {machineIsOff}
  />

  <!-- Floor -->
  <T.Mesh>
    <T.CylinderGeometry args={[1, 1, 0.04, 64]} />
    <T.MeshStandardMaterial color={'#0f0f0f'} />
  </T.Mesh>
</T.Scene>
export enum StickPosition {
  Left,
  Idle,
  Right
}

export enum Button {
  Idle,
  Pressed
}
<script lang="ts">
  import { T } from '@threlte/core'
  import { Edges, Text } from '@threlte/extras'
  import { cubicIn, cubicOut } from 'svelte/easing'
  import { Tween } from 'svelte/motion'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import { game } from './Game.svelte'

  let mainUiTexts = $derived.by(() => {
    if (game.state === 'game-over')
      return {
        text: `Game Over\nScore: ${game.score}`,
        size: {
          width: 7,
          height: 2.5
        }
      }
    if (game.state === 'menu')
      return {
        text: 'Press Space\nto Start',
        size: {
          width: 7.5,
          height: 2.5
        }
      }
    if (game.state === 'level-complete')
      return {
        text: `Level ${game.levelIndex + 1} Complete\nScore: ${game.score}`,
        size: {
          width: 10,
          height: 2.5
        }
      }
    return undefined
  })

  const scale = new Tween(0)

  $effect(() => {
    const inAnim = !!mainUiTexts
    scale.set(inAnim ? 0.8 : 0, {
      easing: inAnim ? cubicIn : cubicOut
    })
  })
</script>

<T.Group
  scale={scale.current}
  position.y={2}
>
  <!-- Centered UI background -->
  {#key `${[(mainUiTexts?.size.width ?? 6.5).toString(), (mainUiTexts?.size.height ?? 2.5).toString()].join('')}`}
    <T.Mesh
      rotation.x={-90 * DEG2RAD}
      position.y={0.8}
    >
      <T.PlaneGeometry args={[mainUiTexts?.size.width ?? 6.5, mainUiTexts?.size.height ?? 2.5]} />
      <T.MeshBasicMaterial color="#08060a" />

      <Edges
        color={game.baseColor}
        scale={1.01}
      />
    </T.Mesh>
  {/key}

  <!-- Centered UI Text -->
  {#if mainUiTexts?.text}
    <Text
      font="/fonts/beefd.ttf"
      rotation.x={DEG2RAD * -90}
      anchorX="50%"
      anchorY="50%"
      textAlign="center"
      fontSize={0.4}
      lineHeight={2}
      color={game.baseColor}
      position.y={1}
      text={mainUiTexts?.text}
    />
  {/if}
</T.Group>

<!-- LEVEL (left column) -->
<Text
  font="/fonts/beefd.ttf"
  rotation.x={-90 * DEG2RAD}
  anchorX="50%"
  anchorY="50%"
  textAlign="center"
  fontSize={0.3}
  color={game.baseColor}
  position={[-4.56, 1, -3.4]}
  text="LVL"
/>
<Text
  rotation.x={-90 * DEG2RAD}
  anchorX="50%"
  anchorY="0%"
  textAlign="center"
  font="/fonts/beefd.ttf"
  lineHeight={1.4}
  fontSize={0.7}
  color={game.baseColor}
  position={[-4.56, 1, -3]}
  text={(game.levelIndex + 1).toString()}
/>

<!-- SCORE (right column) -->
<Text
  rotation.x={-90 * DEG2RAD}
  anchorX="50%"
  anchorY="50%"
  textAlign="center"
  fontSize={0.3}
  font="/fonts/beefd.ttf"
  color={game.baseColor}
  position={[4.56, 1, -3.4]}
  text="SCR"
/>
<Text
  rotation.x={-90 * DEG2RAD}
  anchorX="50%"
  anchorY="0%"
  lineHeight={1.4}
  font="/fonts/beefd.ttf"
  textAlign="center"
  fontSize={0.7}
  color={game.baseColor}
  position={[4.56, 1, -3]}
  text={game.score.toString()}
/>
import { Color, PerspectiveCamera, Scene, Texture } from 'three'
import { levels } from './scenes/levels'
import type { RigidBody } from '@dimforge/rapier3d-compat'
import { Sound } from './sound'

type GameStates =
  | 'off'
  | 'intro'
  | 'await-intro-skip'
  | 'menu'
  | 'game-over'
  | 'await-ball-spawn'
  | 'playing'
  | 'level-loading'
  | 'level-complete'
  | 'outro'

class Game {
  state: GameStates = $state('off')
  sound = new Sound()
  levelIndex: number = $state(0)
  score = $state(0)
  gameOver = $state(false)
  playerPosition = $state(0)
  ballPosition = $state({
    x: 0,
    z: 0
  })
  baseColor = $derived.by(() => {
    if (this.state == 'outro') return 'green'
    return 'red'
  })
  muted = $state(false)
  blinkClock: 0 | 1 = $state(0)
  arcadeMachineScene: Scene | undefined = $state(undefined)
  averageScreenColor = $state(new Color('black'))
  gameScene: Scene | undefined = $state(undefined)
  gameCamera: PerspectiveCamera | undefined = $state(undefined)
  gameTexture: Texture | undefined = $state(undefined)
  ballRigidBody: RigidBody | undefined = $state(undefined)
  debug = $state(false)
  orbitControls = $state(false)
  restart() {
    this.reset()
    this.state = 'menu'
  }
  reset() {
    this.state = 'intro'
    this.levelIndex = 0
    this.gameOver = false
    this.score = 0
    this.playerPosition = 0
    this.ballPosition = {
      x: 0,
      z: 0
    }
  }
  nextLevel() {
    if (this.levelIndex < levels.length - 1) {
      this.levelIndex += 1
      this.state = 'level-loading'
    } else {
      this.state = 'outro'
    }
  }

  switchOff() {
    this.reset()
    this.state = 'off'
  }

  /**
   * Optionally resets the game and starts it again
   */
  startGame() {
    this.state = 'level-loading'
  }
}

export const game = new Game()

export const debugValue = $state(0.5)
<script lang="ts">
  import { useTask, useThrelte } from '@threlte/core'
  import { useFBO } from '@threlte/extras'
  import type { HSL } from 'three'
  import { NearestFilter } from 'three'
  import { game } from './Game.svelte'

  const { renderer } = useThrelte()

  const textureWidth = 300
  const textureHeight = Math.round((textureWidth * 3) / 4)

  const gameRenderTarget = useFBO({
    size: {
      width: textureWidth,
      height: textureHeight
    },
    minFilter: NearestFilter,
    magFilter: NearestFilter
  })

  game.gameTexture = gameRenderTarget.texture

  const pixels = new Uint8Array(textureWidth * textureHeight * 4)

  const hsl: HSL = { h: 0, s: 0, l: 0 }

  const sampleEveryXPixel = 2
  const sampleCount = (textureWidth * textureHeight) / sampleEveryXPixel

  const samplePixels = () => {
    let r = 0
    let g = 0
    let b = 0

    for (let index = 0; index < pixels.length; index += sampleEveryXPixel * 4) {
      r += pixels[index]!
      g += pixels[index + 1]!
      b += pixels[index + 2]!
    }

    r = r / sampleCount
    g = g / sampleCount
    b = b / sampleCount

    game.averageScreenColor.setRGB(r / 255, g / 255, b / 255)
    game.averageScreenColor.getHSL(hsl)
    hsl.s = Math.max(0.4, hsl.s)
    game.averageScreenColor.setHSL(hsl.h, hsl.s, hsl.l)
  }

  let frame = 0
  let renderEvery = 2
  useTask(() => {
    frame += 1
    if (!game.gameScene || !game.gameCamera || frame % renderEvery !== 0) return

    const lastRenderTarget = renderer.getRenderTarget()
    renderer.setRenderTarget(gameRenderTarget)
    renderer.clear()
    renderer.render(game.gameScene, game.gameCamera)
    const context = renderer.getContext()
    context.readPixels(
      0,
      0,
      textureWidth,
      textureHeight,
      context.RGBA,
      context.UNSIGNED_BYTE,
      pixels
    )
    samplePixels()
    renderer.setRenderTarget(lastRenderTarget)
  })
</script>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Tween } from 'svelte/motion'
  import { BackSide, Color, PerspectiveCamera, Scene } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import Arena from './objects/Arena.svelte'
  import Ball from './objects/Ball/Ball.svelte'
  import Renderer from './Renderer.svelte'
  import Intro from './scenes/Intro.svelte'
  import Level from './scenes/Level.svelte'
  import Outro from './scenes/Outro.svelte'
  import Player from './objects/Player.svelte'
  import { game } from './Game.svelte'
  import GUI from './GUI.svelte'

  const onKeyPress = (e: KeyboardEvent) => {
    if (e.key === 'd') {
      game.debug = !game.debug
    }
    if (e.key === 'o') {
      game.orbitControls = !game.orbitControls
    }
    if (e.key !== ' ' || game.state === 'level-loading') return
    e.preventDefault()

    if (game.state === 'await-intro-skip') {
      game.startGame()
    } else if (game.state === 'game-over') {
      game.restart()
    } else if (game.state === 'menu') {
      game.startGame()
    } else if (game.state === 'level-complete') {
      game.nextLevel()
    } else if (game.state === 'await-ball-spawn') {
      game.state = 'playing'
    } else if (game.state === 'outro') {
      game.reset()
    }
  }

  let showLevel = $derived(
    game.state === 'level-loading' ||
      game.state === 'level-complete' ||
      game.state === 'playing' ||
      game.state === 'await-ball-spawn' ||
      game.state === 'game-over'
  )

  let showIntro = $derived(game.state === 'intro' || game.state === 'await-intro-skip')
  let showOutro = $derived(game.state === 'outro')

  let machineIsOff = $derived(game.state === 'off' ? true : false)
  let backgroundColor = $derived(machineIsOff ? 'black' : '#08060a')

  const tweenedBackgroundColor = new Tween(new Color('black'), {
    duration: 1e3
  })
  $effect(() => {
    tweenedBackgroundColor.set(new Color(backgroundColor))
  })
</script>

<svelte:window on:keypress={onKeyPress} />

<Renderer />

<T.Scene
  oncreate={(ref: Scene) => {
    game.gameScene = ref
  }}
>
  <T.Mesh>
    <T.SphereGeometry args={[50, 32, 32]} />
    <T.MeshBasicMaterial
      side={BackSide}
      color={tweenedBackgroundColor.current}
    />
  </T.Mesh>

  <T.PerspectiveCamera
    oncreate={(ref: PerspectiveCamera) => {
      game.gameCamera = ref
    }}
    manual
    args={[50, 4 / 3, 0.1, 100]}
    position={[0, 10, 0]}
    rotation.x={-90 * DEG2RAD}
  />

  <T.AmbientLight intensity={0.3} />

  <T.DirectionalLight position={[4, 10, 2]} />

  {#if showIntro}
    <Intro />
  {:else if showOutro}
    <Outro />
  {:else if game.state !== 'off'}
    <Ball />
    <Arena />
    <Player />
    {#if showLevel}
      {#key game.levelIndex}
        <Level />
      {/key}
    {/if}
    <GUI />
  {/if}
</T.Scene>
export const arenaHeight = 8
export const arenaWidth = 8
export const arenaDepth = 1
export const arenaBorderWidth = 0.2

export const playerToBorderDistance = 0.5

export const playerHeight = 0.2
export const playerWidth = 2
export const playerDepth = 1

export const playerSpeed = 0.34

export const blockGap = 0.1
import type { CollisionEnterEvent } from '@threlte/rapier'
import { Tween } from 'svelte/motion'
import { cubicOut } from 'svelte/easing'
import { game } from '../Game.svelte'

export const useArenaCollisionEnterEvent = () => {
  const opacity = new Tween(0.05, {
    easing: cubicOut
  })

  const onCollision = (event: CollisionEnterEvent) => {
    if (
      !event.detail.targetRigidBody ||
      event.detail.targetRigidBody?.handle !== game.ballRigidBody?.handle
    ) {
      return
    }
    opacity.set(0.7, {
      duration: 0
    })
    opacity.set(0.05, {
      duration: 500
    })
  }

  return {
    onCollision,
    opacity
  }
}
import { onDestroy } from 'svelte'

export const useTimeout = () => {
  const timeoutHandlers = new Set<ReturnType<typeof setTimeout>>()

  const timeout = (callback: () => void, ms: number) => {
    const handler = setTimeout(callback, ms)
    timeoutHandlers.add(handler)
  }

  onDestroy(() => {
    timeoutHandlers.forEach((handler) => clearTimeout(handler))
  })

  return {
    timeout
  }
}
<script lang="ts">
  import { T } from '@threlte/core'
  import { Collider } from '@threlte/rapier'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import { arenaDepth, arenaHeight, arenaWidth } from '../config'
  import { useArenaCollisionEnterEvent } from '../hooks/useArenaCollider'

  const colliderWidth = 10

  const sideGridOpacity = 0.7

  const { onCollision: onTopCollision, opacity: topOpacity } = useArenaCollisionEnterEvent()
  const { onCollision: onLeftCollision, opacity: leftOpacity } = useArenaCollisionEnterEvent()
  const { onCollision: onRightCollision, opacity: rightOpacity } = useArenaCollisionEnterEvent()
</script>

<!-- BACKGROUND GRID -->
<T.CustomGridHelper
  args={[arenaWidth, arenaWidth, arenaHeight, arenaWidth]}
  position.y={-0.5}
>
  <T.LineBasicMaterial
    color="green"
    transparent
    opacity={0.1}
  />
</T.CustomGridHelper>

<!-- LEFT GRID -->
<T.CustomGridHelper
  args={[arenaDepth, arenaDepth, arenaHeight, arenaHeight]}
  rotation.z={90 * DEG2RAD}
  position.x={(arenaWidth / 2) * -1}
>
  <T.LineBasicMaterial
    color="green"
    transparent
    opacity={sideGridOpacity}
  />

  <T.Mesh rotation.x={90 * DEG2RAD}>
    <T.PlaneGeometry args={[arenaDepth, arenaHeight]} />
    <T.MeshBasicMaterial
      color="green"
      transparent
      opacity={leftOpacity}
    />
  </T.Mesh>
</T.CustomGridHelper>

<!-- RIGHT GRID -->
<T.CustomGridHelper
  args={[arenaDepth, arenaDepth, arenaHeight, arenaHeight]}
  rotation.z={90 * DEG2RAD}
  position.x={arenaWidth / 2}
>
  <T.LineBasicMaterial
    color="green"
    transparent
    opacity={sideGridOpacity}
  />

  <T.Mesh rotation.x={-90 * DEG2RAD}>
    <T.PlaneGeometry args={[arenaDepth, arenaHeight]} />
    <T.MeshBasicMaterial
      color="green"
      transparent
      opacity={rightOpacity}
    />
  </T.Mesh>
</T.CustomGridHelper>

<!-- TOP GRID -->
<T.CustomGridHelper
  args={[arenaDepth, arenaDepth, arenaHeight, arenaHeight]}
  rotation.y={90 * DEG2RAD}
  rotation.x={90 * DEG2RAD}
  position.z={(arenaHeight / 2) * -1}
>
  <T.LineBasicMaterial
    color="green"
    transparent
    opacity={sideGridOpacity}
  />

  <T.Mesh rotation.x={-90 * DEG2RAD}>
    <T.PlaneGeometry args={[arenaDepth, arenaHeight]} />
    <T.MeshBasicMaterial
      color="green"
      transparent
      opacity={topOpacity}
    />
  </T.Mesh>
</T.CustomGridHelper>

<!-- LEFT COLLIDER -->
<T.Group position={[(colliderWidth / 2 + arenaWidth / 2) * -1, 0, 0]}>
  <Collider
    oncollisionenter={onLeftCollision}
    shape="cuboid"
    args={[colliderWidth / 2, 1 / 2, arenaHeight / 2]}
  />
</T.Group>

<!-- RIGHT COLLIDER -->
<T.Group position={[colliderWidth / 2 + arenaWidth / 2, 0, 0]}>
  <Collider
    oncollisionenter={onRightCollision}
    shape="cuboid"
    args={[colliderWidth / 2, 1 / 2, arenaHeight / 2]}
  />
</T.Group>

<!-- TOP COLLIDER -->
<T.Group position={[0, 0, (colliderWidth / 2 + arenaHeight / 2) * -1]}>
  <Collider
    oncollisionenter={onTopCollision}
    shape="cuboid"
    args={[(colliderWidth * 2 + arenaWidth) / 2, 1 / 2, colliderWidth / 2]}
  />
</T.Group>

<!-- BOTTOM COLLIDER (acts as the game over zone sensor) -->
<T.Group position={[0, 0, colliderWidth / 2 + arenaHeight / 2]}>
  <Collider
    sensor
    shape="cuboid"
    args={[(colliderWidth * 2 + arenaWidth) / 2, 1 / 2, colliderWidth / 2]}
  />
</T.Group>
<script lang="ts">
  import { game } from '../../Game.svelte'
  import BallOut from './BallOut.svelte'
  import DynamicBall from './DynamicBall.svelte'
  import StaticBall from './StaticBall.svelte'
</script>

{#if game.state === 'playing'}
  <DynamicBall startAtPosX={game.playerPosition} />
{:else if game.state === 'game-over'}
  <BallOut />
{:else}
  <StaticBall />
{/if}
<script>
  import { T } from '@threlte/core'
  import { MeshBasicMaterial } from 'three'
  import { BoxGeometry } from 'three'
  import { Mesh } from 'three'
  import { Group } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import { useTimeout } from '../../hooks/useTimeout'
  import { game } from '../../Game.svelte'

  const geometry = new BoxGeometry(1, 0.01, 0.1)
  const material = new MeshBasicMaterial({
    color: 'red'
  })

  const { timeout } = useTimeout()

  let noBlink = false
  timeout(() => {
    noBlink = true
  }, 1e3)
</script>

<T.Group
  visible={!game.blinkClock || noBlink}
  position.z={game.ballPosition.z}
  position.x={game.ballPosition.x}
  rotation.y={DEG2RAD * 45}
>
  <T.Mesh>
    <T is={geometry} />
    <T is={material} />
  </T.Mesh>

  <T.Mesh rotation.y={DEG2RAD * 90}>
    <T is={geometry} />
    <T is={material} />
  </T.Mesh>
</T.Group>
<script lang="ts">
  import {
    CoefficientCombineRule,
    type RigidBody as RapierRigidBody
  } from '@dimforge/rapier3d-compat'
  import { T, useTask } from '@threlte/core'
  import { AutoColliders, RigidBody } from '@threlte/rapier'
  import { arenaHeight, playerHeight, playerToBorderDistance } from '../../config'
  import { game } from '../../Game.svelte'
  import { ballGeometry, ballMaterial } from './common'
  import { onMount } from 'svelte'

  type Props = {
    startAtPosX: number
  }
  let { startAtPosX }: Props = $props()

  let posX = $state(0)
  let rigidBody: RapierRigidBody | undefined = $state()

  const map = (value: number, inMin: number, inMax: number, outMin: number, outMax: number) => {
    return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
  }

  const ballSpeed = $derived.by(() => {
    return map(game.levelIndex, 0, 9, 0.1, 0.3)
  })

  let ballIsSpawned = false
  const spawnBall = () => {
    if (!rigidBody) return
    ballIsSpawned = true
    const randomSign = Math.random() > 0.5 ? 1 : -1
    const randomX = (randomSign * Math.random() * ballSpeed) / 2
    rigidBody.applyImpulse({ x: randomX, y: 0, z: -ballSpeed }, true)
  }

  const startAtPosZ = arenaHeight / 2 - playerHeight - playerToBorderDistance * 2

  const onSensorEnter = () => {
    if (game.state === 'playing') {
      game.state = 'game-over'
    }
  }

  useTask(() => {
    if (!ballIsSpawned && rigidBody) {
      spawnBall()
      stop()
    }
    const rbTranslation = rigidBody?.translation()
    game.ballPosition = {
      x: rbTranslation?.x ?? 0,
      z: rbTranslation?.z ?? 0
    }
  })
  $effect(() => {
    if (rigidBody) game.ballRigidBody = rigidBody
  })
  onMount(() => {
    posX = startAtPosX
  })
</script>

<T.Group position={[posX, 0, startAtPosZ]}>
  <RigidBody
    bind:rigidBody
    type={'dynamic'}
    onsensorenter={onSensorEnter}
    enabledTranslations={[true, false, true]}
  >
    <AutoColliders
      shape="ball"
      mass={1}
      friction={0}
      restitution={1}
      restitutionCombineRule={CoefficientCombineRule.Max}
      frictionCombineRule={CoefficientCombineRule.Min}
    >
      <T.Mesh>
        <T is={ballGeometry} />
        <T is={ballMaterial} />
      </T.Mesh>
    </AutoColliders>
  </RigidBody>
</T.Group>
<script lang="ts">
  import { T } from '@threlte/core'
  import { arenaHeight, playerHeight, playerToBorderDistance } from '../../config'
  import { game } from '../../Game.svelte'
  import { ballGeometry, ballMaterial } from './common'

  const startAtPosZ = arenaHeight / 2 - playerHeight - playerToBorderDistance * 2

  let usePreviousBallPosition = $derived(
    game.state === 'game-over' || game.state === 'level-complete'
  )
  let combinedPosZ = $derived(usePreviousBallPosition ? game.ballPosition.z : startAtPosZ)
  let combinedPosX = $derived(usePreviousBallPosition ? game.ballPosition.x : game.playerPosition)
</script>

<T.Mesh
  position.z={combinedPosZ}
  position.x={combinedPosX}
>
  <T is={ballGeometry} />
  <T is={ballMaterial} />
</T.Mesh>
import { MeshBasicMaterial, SphereGeometry } from 'three'

export const ballMaterial = new MeshBasicMaterial({
  color: 'blue'
})

export const ballGeometry = new SphereGeometry(0.2)
<script
  lang="ts"
  module
>
  import type { BlockData } from '../objects/types'
  import { T } from '@threlte/core'
  import { Edges } from '@threlte/extras'
  import { Collider, RigidBody, type ContactEvent } from '@threlte/rapier'
  import { cubicIn } from 'svelte/easing'
  import { Tween } from 'svelte/motion'
  import { clamp } from 'three/src/math/MathUtils.js'
  import { game } from '../Game.svelte'
</script>

<script lang="ts">
  type Props = {
    position: BlockData['position']
    size: BlockData['size']
    hit: BlockData['hit']
    freeze: BlockData['freeze']
    staticColors: BlockData['staticColors']
    blinkingColors: BlockData['blinkingColors']
    onHit: () => void
  }
  let { position, size, hit, freeze, staticColors, blinkingColors, onHit }: Props = $props()

  const scale = new Tween(0)
  scale.set(1, {
    easing: cubicIn
  })

  let innerColor = $derived(
    blinkingColors
      ? game.blinkClock === 0
        ? blinkingColors.innerA
        : blinkingColors.innerB
      : staticColors.inner
  )

  let outerColor = $derived(
    blinkingColors
      ? game.blinkClock === 0
        ? blinkingColors.outerA
        : blinkingColors.outerB
      : staticColors.outer
  )

  const onContact = (e: ContactEvent) => {
    if (e.totalForceMagnitude > 2000 || e.totalForceMagnitude < 300) return
    const volume = clamp(Math.max(e.totalForceMagnitude, 0) / 2000, 0, 1)
    game.sound.playFromGroup('bounce', {
      volume
    })
  }
</script>

<T.Group
  position.x={position.x}
  position.z={position.z}
>
  <RigidBody
    type={!hit || freeze ? 'fixed' : 'dynamic'}
    canSleep={false}
    dominance={hit ? -1 : 1}
    enabledTranslations={[true, false, true]}
  >
    <Collider
      shape="cuboid"
      args={[size / 2, 1 / 2, size / 2]}
      oncontact={(e: ContactEvent) => {
        onContact(e)
      }}
      oncollisionexit={() => {
        if (!hit) {
          onHit?.()
        }
      }}
      mass={1}
    >
      <T.Mesh scale={scale.current}>
        <T.BoxGeometry args={[size, 1, size]} />
        <T.MeshStandardMaterial
          color={innerColor}
          transparent
          opacity={0.6}
        />
        <Edges
          color={outerColor}
          scale={1.01}
        />
      </T.Mesh>
    </Collider>
  </RigidBody>
</T.Group>
import {
  BufferGeometry,
  Color,
  Float32BufferAttribute,
  LineBasicMaterial,
  LineSegments
} from 'three'

export class CustomGridHelper extends LineSegments {
  constructor(
    width = 10,
    widthDivisions = 10,
    height = 10,
    heightDivisions = 10,
    color = 0x444444
  ) {
    const colorObj = new Color(color)

    const stepWidth = width / widthDivisions
    const stepHeigth = height / heightDivisions
    const halfSizeHeight = height / 2
    const halfSizeWidth = width / 2

    const vertices = []
    const colors: number[] = []

    for (let i = 0, j = 0, k = -halfSizeHeight; i <= heightDivisions; i++, k += stepHeigth) {
      vertices.push(-halfSizeWidth, 0, k, halfSizeWidth, 0, k)

      colorObj.toArray(colors, j)
      j += 3

      colorObj.toArray(colors, j)
      j += 3

      colorObj.toArray(colors, j)
      j += 3

      colorObj.toArray(colors, j)
      j += 3
    }

    for (let i = 0, j = 0, k = -halfSizeWidth; i <= widthDivisions; i++, k += stepWidth) {
      vertices.push(k, 0, -halfSizeHeight, k, 0, halfSizeHeight)

      colorObj.toArray(colors, j)
      j += 3

      colorObj.toArray(colors, j)
      j += 3

      colorObj.toArray(colors, j)
      j += 3

      colorObj.toArray(colors, j)
      j += 3
    }

    const geometry = new BufferGeometry()
    geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))

    geometry.setAttribute('color', new Float32BufferAttribute(colors, 3))

    const material = new LineBasicMaterial({ vertexColors: true, toneMapped: false })

    super(geometry, material)

    this.type = 'GridHelper'
  }

  dispose() {
    this.geometry.dispose()
    ;(this.material as any).dispose()
  }
}
<script lang="ts">
  import type { Collider } from '@dimforge/rapier3d-compat'
  import { T, useTask } from '@threlte/core'
  import { Edges, useGltf } from '@threlte/extras'
  import { AutoColliders } from '@threlte/rapier'
  import type { Mesh } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import { arenaHeight, arenaWidth, playerHeight, playerSpeed, playerWidth } from '../config'
  import { game } from '../Game.svelte'

  let positionZ = $derived(arenaHeight / 2 - playerHeight)
  let positionX = $state(0)

  let leftPressed = false
  let rightPressed = false

  // 0.12 is a magic number that makes the player barely touch the border
  let posXMax = arenaWidth / 2 - playerWidth / 2 - 0.12
  let playerCanMove = $derived(
    game.state === 'playing' || game.state === 'await-ball-spawn' || game.state === 'level-loading'
  )
  let centerPlayer = $derived(game.state === 'menu' || game.state === 'level-loading')

  useTask((delta) => {
    if (!playerCanMove) {
      if (centerPlayer) {
        positionX = 0
      } else {
        positionX = positionX
      }
      return
    }
    if (!leftPressed && !rightPressed) return
    if (leftPressed && rightPressed) return
    if (leftPressed) {
      positionX = Math.max(positionX - (playerSpeed * delta * 60) / 2, -posXMax)
    }
    if (rightPressed) {
      positionX = Math.min(positionX + (playerSpeed * delta * 60) / 2, posXMax)
    }
  })

  $effect(() => {
    game.playerPosition = positionX
  })

  const onKeyUp = (e: KeyboardEvent) => {
    if (e.key === 'ArrowLeft') {
      e.preventDefault()
      leftPressed = false
    } else if (e.key === 'ArrowRight') {
      e.preventDefault()
      rightPressed = false
    }
  }

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'ArrowLeft') {
      e.preventDefault()
      leftPressed = true
    } else if (e.key === 'ArrowRight') {
      e.preventDefault()
      rightPressed = true
    }
  }

  const gltf = useGltf<{
    nodes: { Player: Mesh }
    materials: Record<string, never>
  }>('/models/ball-game/player/player-simple.glb')

  let colliders: Collider[] = $state([])

  useTask(() => {
    if (colliders.length) {
      const collider = colliders[0]!
      collider.setTranslation({ x: positionX, y: 0, z: positionZ })
    }
  })
</script>

<svelte:window
  on:keydown={onKeyDown}
  on:keyup={onKeyUp}
/>

{#if $gltf?.nodes.Player}
  <T.Group>
    <AutoColliders
      shape="convexHull"
      bind:colliders
    >
      <T.Mesh
        position.z={positionZ}
        position.x={positionX}
        rotation.x={DEG2RAD * -90}
        rotation.y={DEG2RAD * 90}
        scale.x={0.5}
        scale.y={0.3}
      >
        <T is={$gltf.nodes.Player.geometry} />
        <T.MeshStandardMaterial color="blue" />

        <Edges
          scale={[1, 1.1, 1.1]}
          thresholdAngle={10}
          color={game.baseColor}
        />
      </T.Mesh>
    </AutoColliders>
  </T.Group>
{/if}
<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import { Edges } from '@threlte/extras'
  import { BoxGeometry, MeshBasicMaterial } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import { game } from '../Game.svelte'

  type Props = {
    scale?: number
    positionZ?: number
    direction?: 1 | -1
  }
  let { scale = 1, positionZ = 0, direction = 1 }: Props = $props()

  const geometry = new BoxGeometry(1, 1, 1)
  const material = new MeshBasicMaterial({
    transparent: true,
    opacity: 0
  })

  let rotationY = $state(0)

  useTask((delta) => {
    rotationY += delta * direction
  })
</script>

<T.Group
  rotation.x={-65 * DEG2RAD}
  rotation.y={rotationY}
  position.z={positionZ}
  {scale}
>
  <T.Mesh>
    <T is={geometry} />
    <T is={material} />
    <Edges color={game.baseColor} />
  </T.Mesh>

  <T.Mesh position.x={1}>
    <T is={geometry} />
    <T is={material} />
    <Edges color={game.baseColor} />
  </T.Mesh>

  <T.Mesh position.x={-1}>
    <T is={geometry} />
    <T is={material} />
    <Edges color={game.baseColor} />
  </T.Mesh>

  <T.Mesh position.z={1}>
    <T is={geometry} />
    <T is={material} />
    <Edges color={game.baseColor} />
  </T.Mesh>

  <T.Mesh position.z={-1}>
    <T is={geometry} />
    <T is={material} />
    <Edges color={game.baseColor} />
  </T.Mesh>

  <T.Mesh position.y={1}>
    <T is={geometry} />
    <T is={material} />
    <Edges color={game.baseColor} />
  </T.Mesh>
</T.Group>
export type BlockData = {
  position: {
    x: number
    z: number
  }
  staticColors: {
    inner: string
    outer: string
  }
  blinkingColors:
    | {
        innerA: string
        innerB: string
        outerA: string
        outerB: string
      }
    | undefined
  hit: boolean
  size: number
  freeze: boolean
}
<script lang="ts">
  import { T } from '@threlte/core'
  import { Edges, Text } from '@threlte/extras'
  import { onDestroy } from 'svelte'
  import { Tween } from 'svelte/motion'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import type { ArcadeAudio } from '../sound'
  import { useTimeout } from '../hooks/useTimeout'
  import { game } from '../Game.svelte'
  import ThrelteLogo from '../objects/ThrelteLogo.svelte'

  const { timeout } = useTimeout()
  let audio: ArcadeAudio | undefined = undefined
  let direction: 1 | -1 = $state(1)

  const logoScale = new Tween(0)

  const showLogoAfter = 2e3
  const showThrelteAfter = showLogoAfter + 1e3
  const showPressSpaceToStartAfter = showThrelteAfter + 2e3

  timeout(() => {
    audio = game.sound.play('levelSlow', {
      loop: true,
      volume: 1
    })
    logoScale.set(1)
    game.state = 'await-intro-skip'
  }, showLogoAfter)

  const textScale = new Tween(0)
  const textRotation = new Tween(10)
  timeout(() => {
    textScale.set(1)
    textRotation.set(0)
  }, showThrelteAfter)

  let showPressSpaceToStart = $state(false)
  let blinkClock: 0 | 1 = $state(0)

  timeout(() => {
    showPressSpaceToStart = true
  }, showPressSpaceToStartAfter)

  let intervalHandler = setInterval(() => {
    if (!showPressSpaceToStart) return
    blinkClock = blinkClock ? 0 : 1
  }, 500)
  onDestroy(() => {
    clearInterval(intervalHandler)
  })

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'ArrowLeft') {
      direction = -1
    } else if (e.key === 'ArrowRight') {
      direction = 1
    }
  }

  onDestroy(() => {
    audio?.source.stop()
  })
</script>

<svelte:window on:keydown={onKeyDown} />

<T.Group position.z={-0.35}>
  <ThrelteLogo
    positionZ={-1.2}
    scale={logoScale.current}
    {direction}
  />

  <T.Group
    scale={textScale.current}
    position.z={1.3}
    rotation.x={-90 * DEG2RAD}
    rotation.z={textRotation.current}
  >
    <T.Mesh position.y={-0.05}>
      <T.PlaneGeometry args={[5.3, 1.8]} />
      <T.MeshBasicMaterial
        transparent
        opacity={0}
      />
      <Edges color={game.baseColor} />
    </T.Mesh>
    <Text
      font="/fonts/beefd.ttf"
      anchorX="50%"
      anchorY="50%"
      textAlign="center"
      fontSize={0.5}
      color={game.baseColor}
      text={`THRELTE\nMASTER`}
    />
  </T.Group>
</T.Group>

{#if showPressSpaceToStart}
  <T.Group
    scale={textScale.current}
    position.z={3.3}
    rotation.x={-90 * DEG2RAD}
    visible={!!blinkClock}
  >
    <Text
      font="/fonts/beefd.ttf"
      anchorX="50%"
      anchorY="50%"
      textAlign="center"
      fontSize={0.35}
      color={game.baseColor}
      text={`PRESS SPACE TO START`}
    />
  </T.Group>
{/if}
<script lang="ts">
  import { onDestroy } from 'svelte'
  import type { ArcadeAudio } from '../sound'
  import { arenaBorderWidth, arenaHeight, arenaWidth, blockGap } from '../config'
  import { useTimeout } from '../hooks/useTimeout'
  import { levels } from './levels'
  import { game } from '../Game.svelte'
  import Block from '../objects/Block.svelte'
  import type { BlockData } from '../objects/types'

  let blocks: BlockData[] = $state([])

  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

  let bgVolume = 0.6

  let levelBackgroundAudio: ArcadeAudio | undefined = undefined

  const map = (value: number, inMin: number, inMax: number, outMin: number, outMax: number) => {
    return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
  }

  const playLevelSound = () => {
    levelBackgroundAudio = game.sound.play('levelSlow', {
      loop: true,
      volume: bgVolume,
      playbackRate: map(game.levelIndex, 0, levels.length - 1, 1.0, 2)
    })
  }

  playLevelSound()

  onDestroy(() => {
    if (levelBackgroundAudio) {
      levelBackgroundAudio.source.stop()
    }
  })

  let levelStarted = false
  const buildBlocks = async () => {
    if (game.state !== 'level-loading') return
    const { rows, columns } = levels[game.levelIndex]!

    const blockSize =
      (arenaWidth - arenaBorderWidth - ((columns - 1) * blockGap + 2 * blockGap)) / columns
    const startAtX = ((arenaWidth - arenaBorderWidth) / 2) * -1 + blockSize / 2
    const startAtZ = ((arenaHeight - arenaBorderWidth) / 2) * -1 + blockSize / 2 + blockGap

    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < columns; j++) {
        blocks.push({
          position: {
            x: startAtX + blockGap + j * blockGap + j * blockSize,
            z: startAtZ + i * blockGap + i * blockSize
          },
          hit: false,
          size: blockSize,
          freeze: false,
          staticColors: {
            inner: 'blue',
            outer: 'red'
          },
          blinkingColors: undefined
        })
        blocks = blocks
        await wait(16)
      }
    }

    levelStarted = true
    game.state = 'await-ball-spawn'
  }

  const { timeout } = useTimeout()

  buildBlocks()

  const onGameOver = async () => {
    if (!levelStarted) return
    blocks.forEach((block) => {
      block.freeze = true
      if (!block.hit) {
        block.blinkingColors = {
          innerA: 'red',
          innerB: 'black',
          outerA: 'red',
          outerB: 'red'
        }
      } else {
        block.blinkingColors = undefined
        block.staticColors = {
          inner: 'black',
          outer: 'red'
        }
      }
    })
    timeout(() => {
      blocks.forEach((block) => {
        block.blinkingColors = undefined
        if (!block.hit) {
          block.staticColors = {
            inner: 'red',
            outer: 'red'
          }
        } else {
          block.staticColors = {
            inner: 'black',
            outer: 'red'
          }
        }
      })
    }, 1e3)
    if (levelBackgroundAudio) levelBackgroundAudio.fade(0, 300)
    game.sound.play('gameOver2', { volume: 0.5 })?.onEnded()
  }

  const onLevelComplete = async () => {
    if (!levelStarted) return
    blocks.forEach((block) => {
      block.freeze = true
      block.blinkingColors = {
        innerA: 'black',
        innerB: 'green',
        outerA: 'white',
        outerB: 'white'
      }
    })
    timeout(() => {
      blocks.forEach((block) => {
        block.staticColors = {
          inner: 'green',
          outer: 'white'
        }
        block.blinkingColors = undefined
      })
    }, 1e3)
    if (levelBackgroundAudio) levelBackgroundAudio.fade(0.2, 200)
    await game.sound.play('levelComplete')?.onEnded()
    if (levelBackgroundAudio) levelBackgroundAudio.fade(bgVolume, 200)
  }

  $effect(() => {
    if (game.state === 'game-over') onGameOver()
    if (game.state === 'level-complete') onLevelComplete()
  })

  const onHit = (block: BlockData) => {
    if (game.state === 'game-over' || game.state === 'level-complete') return
    game.score += 1
    block.hit = true
    block.blinkingColors = undefined
    block.staticColors = {
      inner: 'yellow',
      outer: 'red'
    }
    if (blocks.every((block) => block.hit)) {
      game.state = 'level-complete'
    }
  }
</script>

{#each blocks as block, index (index)}
  <Block
    {...block}
    onHit={() => {
      onHit(block)
    }}
  />
{/each}
<script lang="ts">
  import { T } from '@threlte/core'
  import { Edges, Text } from '@threlte/extras'
  import { onDestroy } from 'svelte'
  import { Tween } from 'svelte/motion'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import type { ArcadeAudio } from '../sound'
  import { useTimeout } from '../hooks/useTimeout'
  import { game } from '../Game.svelte'
  import ThrelteLogo from '../objects/ThrelteLogo.svelte'

  const { timeout } = useTimeout()
  let direction: 1 | -1 = $state(1)
  const logoScale = new Tween(0)
  timeout(() => {
    logoScale.set(1)
  }, 1.5e3)

  const textScale = new Tween(0)
  const textRotation = new Tween(10)

  timeout(() => {
    textScale.set(1)
    textRotation.set(0)
  }, 200)

  let showPressSpaceToStart = $state(false)
  let blinkClock: 0 | 1 = $state(0)

  timeout(() => {
    showPressSpaceToStart = true
  }, 5e3)

  let intervalHandler = setInterval(() => {
    if (!showPressSpaceToStart) return
    blinkClock = blinkClock ? 0 : 1
  }, 500)
  onDestroy(() => {
    clearInterval(intervalHandler)
  })

  let audio: ArcadeAudio | undefined = undefined
  audio = game.sound.play('intro', {
    loop: true,
    volume: 1
  })
  onDestroy(() => {
    audio?.source.stop()
  })

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'ArrowLeft') {
      direction = -1
    } else if (e.key === 'ArrowRight') {
      direction = 1
    }
  }
</script>

<svelte:window on:keydown={onKeyDown} />

<T.Group position.z={-0.35}>
  <ThrelteLogo
    positionZ={-1.2}
    {direction}
  />

  <T.Group
    scale={textScale.current}
    position.z={1.3}
    rotation.x={-90 * DEG2RAD}
    rotation.z={textRotation}
  >
    <T.Mesh position.y={-0.05}>
      <T.PlaneGeometry args={[11, 2]} />
      <T.MeshBasicMaterial
        transparent
        opacity={0}
      />
      <Edges color={game.baseColor} />
    </T.Mesh>
    <Text
      font="/fonts/beefd.ttf"
      anchorX="50%"
      anchorY="50%"
      textAlign="center"
      fontSize={0.5}
      color={game.baseColor}
      text={`THRELTE MASTER\nSCORE ${game.score}`}
    />
  </T.Group>
</T.Group>

{#if showPressSpaceToStart}
  <T.Group
    scale={textScale.current}
    position.z={3.3}
    rotation.x={-90 * DEG2RAD}
    visible={!!blinkClock}
  >
    <Text
      font="/fonts/beefd.ttf"
      anchorX="50%"
      anchorY="50%"
      textAlign="center"
      fontSize={0.35}
      color={game.baseColor}
      text={`PRESS SPACE TO RESTART`}
    />
  </T.Group>
{/if}
export type Level = {
  rows: number
  columns: number
}

export const levels: Level[] = [
  {
    rows: 1,
    columns: 4
  },
  {
    rows: 1,
    columns: 8
  },
  {
    rows: 2,
    columns: 4
  },
  {
    rows: 1,
    columns: 12
  },
  {
    rows: 2,
    columns: 8
  },
  {
    rows: 3,
    columns: 6
  },
  {
    rows: 3,
    columns: 12
  },
  {
    rows: 4,
    columns: 16
  },
  {
    rows: 5,
    columns: 12
  },
  {
    rows: 5,
    columns: 16
  }
]
import { AudioLoader } from 'three'

const sounds = {
  bounce1: '/audio/ball_bounce_1.mp3',
  bounce2: '/audio/ball_bounce_2.mp3',
  bounce3: '/audio/ball_bounce_3.mp3',
  bounce4: '/audio/ball_bounce_4.mp3',
  bounce5: '/audio/ball_bounce_5.mp3',
  bounce6: '/audio/ball_bounce_6.mp3',
  bounce7: '/audio/ball_bounce_7.mp3',
  bounce8: '/audio/ball_bounce_8.mp3',
  bounce9: '/audio/ball_bounce_9.mp3',
  intro: '/audio/arcade_intro.mp3',
  intro2: '/audio/arcade_intro2.m4a',
  intro3: '/audio/arcade_intro3.m4a',
  levelSlow: '/audio/level_slow.m4a',
  levelComplete: '/audio/level_complete.m4a',
  gameOver: '/audio/game_over.m4a',
  gameOver2: '/audio/game_over2.m4a'
} as const
type Sounds = keyof typeof sounds

type Groups = 'bounce'

type PlayOptions = {
  when?: number
  loop?: boolean
  volume?: number
  playbackRate?: number
}

export type ArcadeAudio = {
  source: AudioBufferSourceNode
  gain: GainNode
  fade: (
    volume: number,
    duration: number,
    options?: { type?: 'linear' | 'exponential' }
  ) => Promise<void> | undefined
  setVolume: (volume: number) => void
  onEnded: () => Promise<void>
}

export class Sound {
  context: AudioContext | undefined = undefined
  globalGainNode: GainNode | undefined = undefined
  groups: Record<Groups, Sounds[]> = {
    bounce: [
      'bounce1',
      'bounce2',
      'bounce3',
      'bounce4',
      'bounce5',
      'bounce6',
      'bounce7',
      'bounce8',
      'bounce9'
    ]
  }
  audioBuffers: Record<Sounds, AudioBuffer> = {} as Record<Sounds, AudioBuffer>
  buffersLoaded = false
  debounceInMs = 150
  randomLimits: [min: number, max: number] = [-20, 150]

  audioLoader = new AudioLoader()
  lastPlayed: Record<Groups, number> = Object.keys(this.groups).reduce(
    (acc, key) => {
      acc[key as Groups] = 0
      return acc
    },
    {} as Record<Groups, number>
  )
  constructor() {
    this.context = new AudioContext()
    this.globalGainNode = this.context.createGain()
    this.globalGainNode.connect(this.context.destination)
    this.initAudio()
    if (typeof window === 'undefined') return
    window.addEventListener('click', () => {
      if (this.context) this.context.resume()
    })
    window.addEventListener('keydown', () => {
      if (this.context) this.context.resume()
    })
  }
  async initAudio() {
    const promises = Object.entries(sounds).map(async ([sound, url]) => {
      if (!this.context) return
      const audioBuffer = await this.loadAudioBuffer(url)
      this.audioBuffers[sound as Sounds] = audioBuffer
    })
    await Promise.all(promises)
    this.buffersLoaded = true
  }
  resume() {
    if (this.context) this.context.resume()
  }
  loadAudioBuffer(url: string): Promise<AudioBuffer> {
    return new Promise((resolve) => {
      this.audioLoader.load(url, (buffer) => {
        resolve(buffer)
      })
    })
  }
  play(sound: Sounds, options?: PlayOptions): ArcadeAudio | undefined {
    if (!this.context || !this.globalGainNode) return
    const now = Date.now()
    const groupsOfSound = Object.entries(this.groups).filter(([, sounds]) => sounds.includes(sound))

    const randomDebounce =
      this.debounceInMs +
      (Math.random() * (this.randomLimits[1] - this.randomLimits[0]) + this.randomLimits[0])
    const shouldBeSkipped = groupsOfSound.reduce((shouldBeSkipped, [group]) => {
      const lastPlayedTime = this.lastPlayed[group as Groups]
      if (now - lastPlayedTime < randomDebounce) return true
      return shouldBeSkipped
    }, false)
    if (shouldBeSkipped) return

    const buffer = this.audioBuffers[sound]
    if (!buffer) return
    if (this.context.state === 'suspended' || this.context.state === 'closed') this.context.resume()
    const source = this.context.createBufferSource()
    source.buffer = buffer
    const gainNode = this.context.createGain()
    source.connect(gainNode)
    gainNode.connect(this.globalGainNode)
    let volume = options?.volume ?? 1
    volume = volume === 0 ? 0.000000001 : volume
    gainNode.gain.value = volume
    source.loop = options?.loop ?? false
    source.playbackRate.value = options?.playbackRate ?? 1
    source.start(options?.when)

    groupsOfSound.forEach(([group]) => {
      this.lastPlayed[group as Groups] = now
    })

    const setVolume = (volume: number) => {
      if (!this.context) return
      gainNode.gain.cancelScheduledValues(this.context.currentTime)
      gainNode.gain.value = volume
    }

    const fade = (
      volume: number,
      duration: number,
      options?: {
        type?: 'linear' | 'exponential'
      }
    ) => {
      if (!this.context) return
      if (volume === 0) {
        volume = 0.000000001
      }
      gainNode.gain.setValueAtTime(gainNode.gain.value, this.context.currentTime)
      if (options?.type === 'exponential') {
        gainNode.gain.exponentialRampToValueAtTime(
          volume,
          this.context.currentTime + duration / 1000
        )
      } else {
        gainNode.gain.exponentialRampToValueAtTime(
          volume,
          this.context.currentTime + duration / 1000
        )
      }
      return new Promise<void>((resolve) => setTimeout(resolve, duration))
    }

    return {
      source,
      gain: gainNode,
      fade,
      setVolume,
      onEnded: () => {
        return new Promise((resolve) => {
          const onEnded = () => {
            source.removeEventListener('ended', onEnded)
            resolve()
          }
          source.addEventListener('ended', onEnded)
        })
      }
    }
  }
  playFromGroup(group: Groups, options?: PlayOptions) {
    const sound = this.groups[group][Math.floor(Math.random() * this.groups[group].length)]
    const source = this.play(sound!, options)
    return source
  }
  handleMuted(muted: boolean) {
    if (!this.globalGainNode) return
    if (muted) {
      this.globalGainNode.gain.value = 0
    } else {
      this.resume()
      this.globalGainNode.gain.value = 1
    }
  }
}