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 ArcadeScene from './arcade/Scene.svelte' import GameScene from './game/Scene.svelte' import { game } from './game/Game.svelte' import CustomRendering from './Renderer.svelte'
$effect(() => { const intervalHandler = window.setInterval(() => { game.blinkClock = game.blinkClock === 0 ? 1 : 0 }, 96) return () => 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(() => { pointLightIntensity.set(pointLightsOff ? 1 : 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 = $state.raw<Object3D>() let camera = $state.raw<PerspectiveCamera>()
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 bind:ref={cameraTarget} 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 bind:ref={camera} position.x={cameraPos.current.x} position.y={cameraPos.current.y} position.z={cameraPos.current.z} fov={30} makeDefault /> {/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 { MathUtils } from 'three' import { T } from '@threlte/core' import { Edges, Text } from '@threlte/extras' import { cubicIn, cubicOut } from 'svelte/easing' import { Tween } from 'svelte/motion' 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 * MathUtils.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={MathUtils.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 * MathUtils.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 * MathUtils.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 * MathUtils.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 * MathUtils.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 = $state<GameStates>('off') sound = new Sound() levelIndex = $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 = $state<0 | 1>(0) arcadeMachineScene = $state<Scene>() averageScreenColor = $state(new Color('black')) gameScene = $state<Scene>() gameCamera = $state<PerspectiveCamera>() gameTexture = $state<Texture>() ballRigidBody = $state<RigidBody>() 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, MathUtils } from 'three' 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 {onkeypress} />
<Renderer />
<T.Scene bind:ref={game.gameScene}> <T.Mesh> <T.SphereGeometry args={[50, 32, 32]} /> <T.MeshBasicMaterial side={BackSide} color={tweenedBackgroundColor.current} /> </T.Mesh>
<T.PerspectiveCamera bind:ref={game.gameCamera} manual args={[50, 4 / 3, 0.1, 100]} position={[0, 10, 0]} rotation.x={-90 * MathUtils.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 = 8export const arenaWidth = 8export const arenaDepth = 1export const arenaBorderWidth = 0.2
export const playerToBorderDistance = 0.5
export const playerHeight = 0.2export const playerWidth = 2export const playerDepth = 1
export const playerSpeed = 0.34
export const blockGap = 0.1import 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: CollisionEnterEvent = (event) => { if (!event.targetRigidBody || event.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.current} /> </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.current} /> </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.current} /> </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"> import type { BlockData } from '../objects/types' import { T } from '@threlte/core' import { Edges } from '@threlte/extras' import { Collider, RigidBody } 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'
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 )</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) => { if (e.totalForceMagnitude > 2000 || e.totalForceMagnitude < 300) return const volume = clamp(Math.max(e.totalForceMagnitude, 0) / 2000, 0, 1) game.sound.playFromGroup('bounce', { volume }) }} 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 { override type = 'GridHelper' 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) }
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, MathUtils } from 'three' 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 = $state<Collider[]>([])
useTask(() => { if (colliders.length) { const collider = colliders[0]! collider.setTranslation({ x: positionX, y: 0, z: positionZ }) } })</script>
<svelte:window {onkeydown} {onkeyup}/>
{#if $gltf?.nodes.Player} <T.Group> <AutoColliders shape="convexHull" bind:colliders > <T.Mesh position.z={positionZ} position.x={positionX} rotation.x={MathUtils.DEG2RAD * -90} rotation.y={MathUtils.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, MathUtils } from 'three' 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 * MathUtils.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 = $state<1 | -1>(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 = window.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 {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 = $state<BlockData[]>([])
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 { MathUtils } from 'three' import { T } from '@threlte/core' import { Edges, Text } from '@threlte/extras' import { onDestroy } from 'svelte' import { Tween } from 'svelte/motion' 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 = $state<1 | -1>(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 = $state<0 | 1>(0)
timeout(() => { showPressSpaceToStart = true }, 5e3)
let intervalHandler = window.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 {onkeydown} />
<T.Group position.z={-0.35}> <ThrelteLogo positionZ={-1.2} {direction} />
<T.Group scale={textScale.current} position.z={1.3} rotation.x={-90 * MathUtils.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 * MathUtils.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 consttype 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 } }}