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
}
}
}