PHYSICS AND LABELS
Ten frames, all data.
Set where the ball starts, its line and its speed, then roll. Rapier simulates the ball and pins; sensors tell the Model what happened. Each pin's spot is a sensor collider named for it, so a pin's head leaving its spot says that pin is down, and a sensor in the pit says the ball has gone through. A Command waits for the pins to settle, the Model counts them, and ten-pin scoring is a pure function of the rolls. The scoreboard is a troika label coloured by colour ranges, its panel sized from its measured layout. Every roll records only what you did: Replay this game, or share its link, and the simulation knocks the same pins down again, checked roll by roll.
What happened, as Messages.
The Model never reads where a pin is. It hears which spots were left and when the ball reached the pit, and keeps score from that, so the whole game replays from its Messages.
Explore the example source →See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
model.ts
import { perspectiveCamera, SceneSchema } from '@opsydyn/effect-three';
import { Schema } from 'effect';
/** The ten pins, by the numbers bowlers give them: 1 at the front, 7 to 10 at the back. */
export const pinIds = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9', 'p10'] as const;
export type PinId = (typeof pinIds)[number];
export type Block = readonly [number, number, number, number];
/**
* `racking` gives a fresh or swept rack time to come to rest (Rapier puts resting bodies
* to sleep) before a roll may meet it, so a roll meets the same world however long the
* player waited: what makes a replay exact (ADR-011).
*/
export const Phase = Schema.Literals(['racking', 'aiming', 'rolling', 'settling', 'over']);
export type Phase = typeof Phase.Type;
export const Shot = Schema.Struct({
aim: Schema.Number,
angle: Schema.Number,
speed: Schema.Number,
});
export const Model = Schema.Struct({
assetBase: Schema.String,
/** Pins knocked down by each roll so far: the scoresheet is derived from these. */
rolls: Schema.Array(Schema.Number),
/** Each fresh rack gets new pin keys, so the pins stand up again. */
rack: Schema.Number,
/** Each ball gets a new key; its roll is an impulse with this id. */
shot: Schema.Number,
/** Pins whose heads are on their spots, as the spots' sensors report it. */
standing: Schema.Array(Schema.String),
/** The pins standing when this roll began: they stay drawn until it is counted. */
before: Schema.Array(Schema.String),
phase: Phase,
/** Where the ball starts across the lane, metres from the middle. */
aim: Schema.Number,
/** Its line, degrees from straight down the lane (positive to the right). */
angle: Schema.Number,
/** How fast it leaves, metres a second. */
speed: Schema.Number,
/** The hardest the ball hit anything this roll, as contact forces reported. */
impact: Schema.Number,
/** What the player did on each roll this game: a replay feeds these back. */
shots: Schema.Array(Shot),
/** A replay in progress or finished: the record, and how many of its shots have rolled. */
replay: Schema.NullOr(
Schema.Struct({
shots: Schema.Array(Shot),
rolls: Schema.Array(Schema.Number),
rolled: Schema.Number,
}),
),
/** The scoreboard label's laid-out block, for the panel behind it. */
layout: Schema.NullOr(Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])),
camera: SceneSchema.Camera,
});
export type Model = typeof Model.Type;
export const limits = {
aim: { min: -0.38, max: 0.38 },
angle: { min: -4, max: 4 },
speed: { min: 4, max: 10 },
} as const;
export const homeCamera = perspectiveCamera({
fov: 48,
position: [0.9, 1.3, 8.3],
lookAt: [0, 0.15, -1.8],
damping: 0.08,
});
export const initialModel = (assetBase: string): Model => ({
assetBase,
rolls: [],
rack: 0,
shot: 0,
standing: [...pinIds],
before: [...pinIds],
phase: 'racking',
aim: 0.08,
angle: -0.6,
speed: 8,
impact: 0,
shots: [],
replay: null,
layout: null,
camera: homeCamera,
});
message.ts
import { SceneSchema } from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';
export const Message = defineMessageUnion({
ChangedAim: { aim: Schema.Number },
ChangedAngle: { angle: Schema.Number },
ChangedSpeed: { speed: Schema.Number },
ClickedRoll: {},
ClickedNewGame: {},
/** Replay this game's rolls from a fresh rack, and check the pins fall the same. */
ClickedReplay: {},
/** The page's address fragment, read at start: a replay link starts that replay. */
ReadFragment: { fragment: Schema.String },
/** Rack `shot` has rested: rolling may begin. */
RackRested: { shot: Schema.Number },
/** A pin's head left or reached a spot, or the ball reached the pit. */
Collided: { collision: SceneSchema.CollisionDetail },
/** The ball hit something harder than its threshold. */
Struck: { force: Schema.Number },
/** Time for the pins to settle after shot `shot`: count what is standing. */
Settled: { shot: Schema.Number },
MeasuredScoreboard: { measured: SceneSchema.LabelMeasuredDetail },
MadeGesture: { gesture: SceneSchema.Gesture },
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { type BodyCollision, type Gesture, orbit, orbitOptions } from '@opsydyn/effect-three';
import { Option } from 'effect';
import type { Return } from 'foldkit/update';
import { RackRest, ReadFragment, SettleAfter } from './command';
import { Message } from './message';
import { initialModel, limits, type Model, pinIds } from './model';
import { decode, type Record } from './replay';
import { finished, freshRack } from './score';
type AppReturn = Return<Model, Message>;
export const pinKey = (model: Model, id: string): string => `pin:${model.rack}:${id}`;
/** The rolled ball: a fresh body, made with its impulse, so its roll has no history. */
export const ballKey = (model: Model): string => `ball:${model.shot}`;
/** The ball waiting at the line: only something to look at. */
export const readyBallKey = (model: Model): string => `ball:${model.shot}:ready`;
const clamp = (value: number, range: { readonly min: number; readonly max: number }) =>
Math.min(range.max, Math.max(range.min, value));
/** The pin a node key names, in this rack. */
const pinOf = (model: Model, key: string): string | undefined =>
pinIds.find((id) => key === pinKey(model, id));
/**
* A pin's head reaching its own spot (`started`) means it stands; leaving it, that it is
* down. A head crossing another pin's spot says nothing.
*/
const spotted = (model: Model, collision: BodyCollision): Model => {
const sides = [
{ key: collision.a, part: collision.bPart },
{ key: collision.b, part: collision.aPart },
];
const pin = sides
.map(({ key, part }) => ({ id: pinOf(model, key), part }))
.find(({ id, part }) => id !== undefined && part === `spot:${id}`)?.id;
if (pin === undefined) return model;
const others = model.standing.filter((id) => id !== pin);
return { ...model, standing: collision.started ? [...others, pin] : others };
};
/** The ball reaching the pit: give the pins a moment to settle, then count. */
const pitted = (model: Model, collision: BodyCollision): AppReturn => {
const ball = ballKey(model);
const inPit =
collision.started &&
((collision.a === ball && collision.bPart === 'pit') ||
(collision.b === ball && collision.aPart === 'pit'));
if (!inPit || model.phase !== 'rolling') return { model };
return {
model: { ...model, phase: 'settling' },
commands: [SettleAfter({ shot: model.shot, seconds: 2.5 })],
};
};
const collided = (model: Model, collision: BodyCollision): AppReturn =>
pitted(spotted(model, collision), collision);
/**
* Count the roll: the pins down are those standing before that no longer are. Then the
* rack (fresh, or swept of the fallen) rests before the next roll may meet it.
*/
const counted = (model: Model): AppReturn => {
const knocked = model.before.filter((id) => !model.standing.includes(id)).length;
const rolls = [...model.rolls, knocked];
const fresh = freshRack(rolls);
const over = finished(rolls);
const next: Model = {
...model,
rolls,
shot: model.shot + 1,
rack: fresh ? model.rack + 1 : model.rack,
standing: fresh ? [...pinIds] : model.standing.filter((id) => model.before.includes(id)),
before: fresh ? [...pinIds] : model.standing.filter((id) => model.before.includes(id)),
phase: over ? 'over' : 'racking',
};
return over ? { model: next } : { model: next, commands: [RackRest({ shot: next.shot })] };
};
/** A replay still has shots to roll. */
const replaying = (model: Model): boolean =>
model.replay !== null && model.replay.rolled < model.replay.shots.length;
const rolled = (model: Model): AppReturn => {
if (model.phase !== 'aiming') return { model };
const shot = { aim: model.aim, angle: model.angle, speed: model.speed };
return {
model: {
...model,
phase: 'rolling',
before: model.standing,
impact: 0,
shots: [...model.shots, shot],
},
// If the ball never reaches the pit (it can stop in a gutter), count anyway.
commands: [SettleAfter({ shot: model.shot, seconds: 7 })],
};
};
const orbitLimits = orbitOptions({ minDistance: 2, maxDistance: 14, maxPolarAngle: 1.5 });
const orbited = (model: Model, gesture: Gesture): Model =>
'key' in gesture && gesture.key !== undefined
? model
: { ...model, camera: orbit(model.camera, gesture, orbitLimits) };
/** A new game: a fresh rack under new keys, the player's aim kept; it rests first. */
const newGame = (model: Model, replay: Record | null): AppReturn => {
const next: Model = {
...initialModel(model.assetBase),
rack: model.rack + 1,
shot: model.shot + 1,
aim: model.aim,
angle: model.angle,
speed: model.speed,
layout: model.layout,
camera: model.camera,
replay: replay === null ? null : { ...replay, rolled: 0 },
};
return { model: next, commands: [RackRest({ shot: next.shot })] };
};
/** The rack has rested: rolling may begin, and a replay rolls its next shot at once. */
const rested = (model: Model): AppReturn => {
const ready: Model = { ...model, phase: 'aiming' };
const replay = ready.replay;
const shot = replay?.shots[replay.rolled];
if (replay === null || shot === undefined) return { model: ready };
return rolled({ ...ready, ...shot, replay: { ...replay, rolled: replay.rolled + 1 } });
};
/** A replay link in the address starts that replay; anything else is left alone. */
const fromFragment = (model: Model, fragment: string): AppReturn =>
Option.match(decode(fragment), {
onNone: () => ({ model }),
onSome: (record) => newGame(model, record),
});
export const init = (props: { readonly assetBase?: string } = {}): AppReturn => ({
model: initialModel(props.assetBase ?? '/models/'),
commands: [ReadFragment(), RackRest({ shot: 0 })],
});
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
ChangedAim: ({ aim }) => ({ model: { ...model, aim: clamp(aim, limits.aim) } }),
ChangedAngle: ({ angle }) => ({ model: { ...model, angle: clamp(angle, limits.angle) } }),
ChangedSpeed: ({ speed }) => ({ model: { ...model, speed: clamp(speed, limits.speed) } }),
// While a replay rolls, the player watches.
ClickedRoll: () => (replaying(model) ? { model } : rolled(model)),
ClickedNewGame: () => newGame(model, null),
ClickedReplay: () =>
model.shots.length === 0
? { model }
: newGame(model, { shots: model.shots, rolls: model.rolls }),
ReadFragment: ({ fragment }) => fromFragment(model, fragment),
RackRested: ({ shot }) =>
shot === model.shot && model.phase === 'racking' ? rested(model) : { model },
Collided: ({ collision }) => collided(model, collision),
Struck: ({ force }) => ({ model: { ...model, impact: Math.max(model.impact, force) } }),
// Only the shot still in play is counted, once: a later or earlier timer is stale.
Settled: ({ shot }) =>
shot === model.shot && (model.phase === 'rolling' || model.phase === 'settling')
? counted(model)
: { model },
MeasuredScoreboard: ({ measured }) => ({ model: { ...model, layout: measured.block } }),
MadeGesture: ({ gesture }) => ({ model: orbited(model, gesture) }),
ReceivedSceneLifecycle: () => ({ model }),
});
score.ts
/**
* Ten-pin scoring, from the pins each roll knocked down: a strike scores ten plus the
* next two rolls, a spare ten plus the next one, and the tenth frame takes its bonus
* rolls in place.
*/
/** One frame as scored: its rolls, and its running total once it can be counted. */
export type Frame = { readonly rolls: ReadonlyArray<number>; readonly total: number | null };
export const framesInGame = 10;
/** The rolls grouped into frames, each with its running total where the bonus is known. */
export const frames = (rolls: ReadonlyArray<number>): ReadonlyArray<Frame> => {
const result: Array<Frame> = [];
let index = 0;
let total = 0;
for (let frame = 0; frame < framesInGame && index < rolls.length; frame++) {
const first = rolls[index] ?? 0;
const last = frame === framesInGame - 1;
if (first === 10 && !last) {
const bonus = rolls.slice(index + 1, index + 3);
total += 10 + bonus.reduce((sum, pins) => sum + pins, 0);
result.push({ rolls: [10], total: bonus.length === 2 ? total : null });
index += 1;
continue;
}
const second = rolls[index + 1];
const taken = last ? rolls.slice(index, index + 3) : rolls.slice(index, index + 2);
if (second === undefined) {
result.push({ rolls: [first], total: null });
break;
}
const open = first + second < 10;
if (last) {
const complete = open ? taken.length >= 2 : taken.length === 3;
total += taken.reduce((sum, pins) => sum + pins, 0);
result.push({ rolls: taken, total: complete ? total : null });
break;
}
const bonus = open ? [] : rolls.slice(index + 2, index + 3);
total += first + second + bonus.reduce((sum, pins) => sum + pins, 0);
result.push({ rolls: [first, second], total: open || bonus.length === 1 ? total : null });
index += 2;
}
return result;
};
/** Whether the game is over: ten frames, the last with its bonus rolls taken. */
export const finished = (rolls: ReadonlyArray<number>): boolean => {
const scored = frames(rolls);
return scored.length === framesInGame && scored.at(-1)?.total !== null;
};
/** The score so far: the last frame with a total. */
export const score = (rolls: ReadonlyArray<number>): number =>
frames(rolls).findLast((frame) => frame.total !== null)?.total ?? 0;
/** Where the next roll falls: its frame (0 to 9) and the rolls that frame has had. */
const position = (rolls: ReadonlyArray<number>) => {
let index = 0;
for (let frame = 0; frame < framesInGame - 1; frame++) {
const taken = (rolls[index] ?? 0) === 10 ? 1 : 2;
if (index + taken > rolls.length) return { frame, rolls: rolls.slice(index) };
index += taken;
}
return { frame: framesInGame - 1, rolls: rolls.slice(index) };
};
/**
* Walks a frame's rolls rack by rack: whether each roll met a fresh rack, and the pins
* left standing after it (a strike or a spare brings a fresh rack in the tenth frame).
*/
const racks = (rolls: ReadonlyArray<number>) => {
let fresh = true;
let left = 10;
return rolls.map((pins) => {
const met = { fresh, left };
left -= pins;
fresh = left === 0 || !fresh;
if (fresh) left = 10;
return met;
});
};
/**
* Whether the next roll starts a fresh rack: a frame's first roll, or in the tenth frame
* one after a strike or a spare.
*/
export const freshRack = (rolls: ReadonlyArray<number>): boolean => {
const { frame, rolls: taken } = position(rolls);
if (taken.length === 0) return true;
if (frame < framesInGame - 1) return false;
const walked = racks(taken);
const last = walked.at(-1);
const pins = taken.at(-1) ?? 0;
if (last === undefined) return true;
return last.left - pins === 0 || !last.fresh;
};
/** How many strikes the rolls so far hold: every X on the scoresheet. */
export const strikes = (rolls: ReadonlyArray<number>): number =>
frames(rolls)
.flatMap((frame) => marks(frame).split(' '))
.filter((mark) => mark === 'X').length;
/** How a frame's rolls read on a scoresheet: X for a strike, / for a spare, - for none. */
export const marks = (frame: Frame): string =>
racks(frame.rolls)
.map(({ fresh, left }, index) => {
const pins = frame.rolls[index] ?? 0;
if (fresh && pins === 10) return 'X';
if (!fresh && pins === left) return '/';
return pins === 0 ? '-' : String(pins);
})
.join(' ');
replay.ts
/**
* A game as a record: what the player did on each roll (start, line, speed), and how many
* pins each roll knocked down. Replaying feeds the inputs back to the simulation and
* checks that the pins fall the same way (ADR-011). The record travels as a short
* fragment, `#replay=…`, decoded and checked before use.
*/
import { Option } from 'effect';
import { limits } from './model';
export type Shot = { readonly aim: number; readonly angle: number; readonly speed: number };
export type Record = {
readonly shots: ReadonlyArray<Shot>;
/** Pins knocked by each roll when recorded: a replay must knock the same. */
readonly rolls: ReadonlyArray<number>;
};
const prefix = '#replay=';
const number = (value: number): string => String(Number(value.toFixed(3)));
/** The record as a URL fragment: `#replay=aim,line,speed;…|pins,pins,…`. */
export const encode = (record: Record): string =>
`${prefix}${record.shots
.map((shot) => [shot.aim, shot.angle, shot.speed].map(number).join(','))
.join(';')}|${record.rolls.join(',')}`;
const within = (value: number, range: { readonly min: number; readonly max: number }) =>
Number.isFinite(value) && value >= range.min && value <= range.max;
const shotOf = (text: string): Option.Option<Shot> => {
const [aim, angle, speed, extra] = text.split(',').map(Number);
if (extra !== undefined || aim === undefined || angle === undefined || speed === undefined) {
return Option.none();
}
return within(aim, limits.aim) && within(angle, limits.angle) && within(speed, limits.speed)
? Option.some({ aim, angle, speed })
: Option.none();
};
/** A fragment back into a record, or nothing if it is not a well-formed one. */
export const decode = (fragment: string): Option.Option<Record> => {
if (!fragment.startsWith(prefix)) return Option.none();
const [shotsText = '', rollsText = '', extra] = decodeURIComponent(
fragment.slice(prefix.length),
).split('|');
if (extra !== undefined || shotsText === '') return Option.none();
const shots = Option.all(shotsText.split(';').map(shotOf));
const rolls = rollsText === '' ? [] : rollsText.split(',').map(Number);
const pinsOk = rolls.every((pins) => Number.isInteger(pins) && pins >= 0 && pins <= 10);
if (!pinsOk || (rolls.length > 0 && rolls.length !== shotsText.split(';').length)) {
return Option.none();
}
return Option.map(shots, (each) => ({ shots: each, rolls }));
};
/** How a replay compares with its record so far: matching, or where it first differs. */
export type Verdict =
| { readonly _tag: 'Matching'; readonly rolls: number }
| {
readonly _tag: 'Differs';
readonly roll: number;
readonly expected: number;
readonly got: number;
};
export const verdict = (expected: ReadonlyArray<number>, got: ReadonlyArray<number>): Verdict => {
const index = got.findIndex(
(pins, roll) => expected[roll] !== undefined && expected[roll] !== pins,
);
const differing = index < 0 ? undefined : got[index];
const wanted = index < 0 ? undefined : expected[index];
return differing === undefined || wanted === undefined
? { _tag: 'Matching', rolls: Math.min(got.length, expected.length) }
: { _tag: 'Differs', roll: index + 1, expected: wanted, got: differing };
};
scene.ts
/**
* A bowling lane. The ball and pins are dynamic bodies; the lane, gutters and pit are
* fixed. What the Model learns comes back through sensors:
*
* - each pin's spot is a sensor collider at head height, named for the pin, so a pin's
* head leaving its spot says that pin is down (and reaching it, that it stands);
* - a sensor named `pit` behind the pins says the ball has gone through;
* - the ball reports contact forces above a threshold: how hard it hit.
*
* The scoreboard is a troika label whose panel is sized from its measured layout.
*/
import {
body,
box,
capsule,
cameraShake,
capsuleCollider,
circle,
colorRange,
contactShadows,
cuboidCollider,
directionalLight,
environment,
fixed,
group,
hemisphereLight,
label,
line,
type LabelColorRange,
mesh,
physical,
phong,
physics,
plane,
renderSettings,
scene,
type SceneNode,
sphere,
spotLight,
standard,
torus,
type Vec3,
} from '@opsydyn/effect-three';
import { paintColour } from '../palette';
import { type Block, type Model, pinIds } from './model';
import { frames, marks, score, strikes } from './score';
import { ballKey, pinKey, readyBallKey } from './update';
/** The lane: its top at y = 0, the foul line at z = 6, the head pin at z = −5. */
const lane = { width: 1.06, near: 6.2, far: -6.2 } as const;
const laneLength = lane.near - lane.far;
const laneMiddle = (lane.near + lane.far) / 2;
const ballRadius = 0.11;
const ballMass = 6.5;
const pinRadius = 0.06;
/** The pin's straight middle; with its rounded ends it stands 0.38 tall. */
const pinMiddle = 0.26;
const pinHeight = pinMiddle + pinRadius * 2;
/** Where each pin stands: four rows back from the head pin, 30 cm apart. */
const spotOf = (index: number): Vec3 => {
const row = index < 1 ? 0 : index < 3 ? 1 : index < 6 ? 2 : 3;
const first = [0, 1, 3, 6][row] ?? 0;
const across = (index - first - row / 2) * 0.3;
return [across, 0, -5 - row * 0.26];
};
const spots = pinIds.map((id, index) => ({ id, at: spotOf(index) }));
const wood = standard({ color: 0xd9a86c, roughness: 0.35, metalness: 0 });
const fixedBox = (key: string, position: Vec3, size: Vec3, material = wood): SceneNode =>
mesh({
key,
position,
geometry: box({ width: size[0], height: size[1], depth: size[2] }),
material,
receiveShadow: true,
body: body({ type: 'fixed', friction: 0.25 }),
});
const gutterMaterial = standard({ color: 0x3a3f4b, roughness: 0.4, metalness: 0.5 });
const sideMaterial = standard({ color: 0x23262e, roughness: 0.8 });
const alley: ReadonlyArray<SceneNode> = [
fixedBox('lane', [0, -0.05, laneMiddle], [lane.width, 0.1, laneLength]),
// Gutters either side, lower than the lane, and the walls beyond them.
fixedBox(
'gutter-left',
[-lane.width / 2 - 0.12, -0.12, laneMiddle],
[0.24, 0.04, laneLength],
gutterMaterial,
),
fixedBox(
'gutter-right',
[lane.width / 2 + 0.12, -0.12, laneMiddle],
[0.24, 0.04, laneLength],
gutterMaterial,
),
fixedBox(
'side-left',
[-lane.width / 2 - 0.3, 0.1, laneMiddle],
[0.12, 0.5, laneLength],
sideMaterial,
),
fixedBox(
'side-right',
[lane.width / 2 + 0.3, 0.1, laneMiddle],
[0.12, 0.5, laneLength],
sideMaterial,
),
// The approach behind the foul line.
fixedBox(
'approach',
[0, -0.05, lane.near + 1.4],
[lane.width + 0.84, 0.1, 2.8],
standard({ color: 0xc9975f, roughness: 0.5 }),
),
// The pit behind the pins, and its back wall.
fixedBox('pit-floor', [0, -0.6, lane.far - 0.8], [lane.width + 0.8, 0.1, 1.6], sideMaterial),
fixedBox('pit-back', [0, 0.2, lane.far - 1.6], [lane.width + 0.8, 1.6, 0.1], sideMaterial),
];
/** A platform under the alley: something to stand on, on a dark page or a light one. */
const floor: SceneNode = mesh({
key: 'floor',
position: [0, -0.66, 0.2],
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: plane({ width: 3.4, height: 17 }),
material: standard({ color: 0x15171d, roughness: 0.9 }),
receiveShadow: true,
});
const markMaterial = standard({ color: 0x5a3b22, roughness: 0.5 });
/**
* The lane's aiming marks, inlaid: seven arrows in a V four metres down, and a row of
* dots near the foul line, as on a real lane.
*/
const laneMarks: ReadonlyArray<SceneNode> = [
...Array.from({ length: 7 }, (_, index) =>
mesh({
key: `arrow:${index}`,
position: [(index - 3) * 0.13, 0.002, 1.6 + Math.abs(index - 3) * 0.16],
// A three-sided circle is a triangle; turned flat, point down the lane.
rotation: fixed([-Math.PI / 2, 0, Math.PI / 2]),
geometry: circle({ radius: 0.05, segments: 3 }),
material: markMaterial,
}),
),
...Array.from({ length: 5 }, (_, index) =>
mesh({
key: `dot:${index}`,
position: [(index - 2) * 0.18, 0.002, 4.6],
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: circle({ radius: 0.018, segments: 12 }),
material: markMaterial,
}),
),
];
/** Sensors: one at each pin's head, named for it, and the pit. */
const sensors: SceneNode = group({
key: 'sensors',
body: body({
type: 'fixed',
sensor: true,
colliders: [
...spots.map(({ id, at }) =>
cuboidCollider({
name: `spot:${id}`,
halfExtents: [0.04, 0.03, 0.04],
position: [at[0], pinHeight - 0.07, at[2]],
}),
),
cuboidCollider({
name: 'pit',
halfExtents: [1, 0.3, 0.7],
position: [0, -0.3, lane.far - 0.8],
}),
],
}),
children: [],
});
const pinWhite = phong({ color: 0xf6f3ee, shininess: 90 });
const pinStripe = phong({ color: 0xd94a3d, shininess: 90 });
const pin = (model: Model, id: string, at: Vec3): SceneNode =>
group({
key: pinKey(model, id),
position: [at[0], pinHeight / 2, at[2]],
body: body({
mass: 1.5,
friction: 0.4,
restitution: 0.3,
colliders: [capsuleCollider({ halfHeight: pinMiddle / 2, radius: pinRadius })],
}),
children: [
mesh({
key: `${pinKey(model, id)}:body`,
geometry: capsule({ radius: pinRadius, height: pinMiddle, radialSegments: 20 }),
material: pinWhite,
castShadow: true,
}),
mesh({
key: `${pinKey(model, id)}:stripe`,
position: [0, pinMiddle / 2 - 0.02, 0],
rotation: fixed([Math.PI / 2, 0, 0]),
geometry: torus({
radius: pinRadius * 0.97,
tube: 0.009,
radialSegments: 8,
tubularSegments: 24,
}),
material: pinStripe,
}),
],
});
/** The pins drawn: those standing, or while a roll is in play, all that stood when it began. */
const pinsShown = (model: Model): ReadonlyArray<SceneNode> => {
const inPlay = model.phase === 'rolling' || model.phase === 'settling';
const shown = inPlay ? model.before : model.standing;
return spots.filter(({ id }) => shown.includes(id)).map(({ id, at }) => pin(model, id, at));
};
const ballGeometry = sphere({ radius: ballRadius, widthSegments: 32, heightSegments: 20 });
const ballMaterial = physical({
color: paintColour.blue,
roughness: 0.15,
metalness: 0.1,
clearcoat: 1,
clearcoatRoughness: 0.05,
});
/**
* While aiming, a dashed line down the lane from the ball (Drei's `Line`): where it will
* go, longer the faster it will leave.
*/
const aimGuide = (model: Model): ReadonlyArray<SceneNode> => {
if (model.phase !== 'aiming') return [];
const radians = (model.angle * Math.PI) / 180;
const reach = 1.5 + model.speed * 0.6;
const start: Vec3 = [model.aim, 0.006, lane.near - 0.5];
const end: Vec3 = [
start[0] + Math.sin(radians) * reach,
0.006,
start[2] - Math.cos(radians) * reach,
];
return [
line({
key: 'aim-guide',
points: [start, end],
vertexColors: [paintColour.gold, 0xffffff],
width: 3,
dashed: true,
dashSize: 0.25,
gapSize: 0.15,
worldUnits: false,
transparent: true,
opacity: 0.9,
}),
];
};
/**
* The ball at the foul line where the Model aims it. Waiting, it is only something to
* look at; rolled, it is a fresh body made with its impulse, so the roll starts from the
* same state however long the player took to aim (ADR-011).
*/
const ball = (model: Model): SceneNode => {
const position: Vec3 = [model.aim, ballRadius + 0.002, lane.near - 0.5];
if (model.phase !== 'rolling' && model.phase !== 'settling') {
return mesh({
key: readyBallKey(model),
position,
geometry: ballGeometry,
material: ballMaterial,
castShadow: true,
});
}
const radians = (model.angle * Math.PI) / 180;
const impulse: Vec3 = [
Math.sin(radians) * model.speed * ballMass,
0,
-Math.cos(radians) * model.speed * ballMass,
];
return mesh({
key: ballKey(model),
position,
geometry: ballGeometry,
material: ballMaterial,
castShadow: true,
body: body({
collider: 'ball',
mass: ballMass,
friction: 0.12,
restitution: 0.05,
ccd: true,
contactForces: 120,
impulse: { id: model.shot, impulse },
}),
});
};
/** The scoresheet line: each frame's marks, strikes and spares in gold. */
/** The scoreboard's text, and the colour ranges that pick out strikes and spares. */
export type Sheet = { readonly text: string; readonly ranges: ReadonlyArray<LabelColorRange> };
export const sheet = (model: Model): Sheet => {
const scored = frames(model.rolls);
const cells = Array.from({ length: 10 }, (_, index) => {
const frame = scored[index];
return frame === undefined ? '·' : marks(frame);
});
const top = cells.join(' | ');
const text = `${top}\nSCORE ${score(model.rolls)}`;
const ranges: Array<LabelColorRange> = [colorRange(0, 0xe6e3dc)];
[...top].forEach((character, index) => {
if (character === 'X' || character === '/') {
ranges.push(colorRange(index, paintColour.gold), colorRange(index + 1, 0xe6e3dc));
}
});
ranges.push(colorRange(top.length + 1, paintColour.periwinkle));
return { text, ranges };
};
const scoreboardAt: Vec3 = [0, 1.75, lane.far - 1.5];
const scoreboard = (model: Model): ReadonlyArray<SceneNode> => {
const { text, ranges } = sheet(model);
const block: Block | undefined = model.layout ?? undefined;
return [
...(block === undefined
? []
: [
mesh({
key: 'scoreboard:panel',
position: [
scoreboardAt[0] + (block[0] + block[2]) / 2,
scoreboardAt[1] + (block[1] + block[3]) / 2,
scoreboardAt[2] - 0.02,
],
geometry: plane({
width: block[2] - block[0] + 0.5,
height: block[3] - block[1] + 0.35,
}),
material: standard({
color: 0x14161c,
roughness: 0.6,
emissive: 0x1d2433,
emissiveIntensity: 1,
}),
}),
]),
label({
key: 'scoreboard',
text,
font: `${model.assetBase}inter-latin-400-normal.woff`,
fontSize: 0.24,
lineHeight: 1.5,
textAlign: 'center',
position: scoreboardAt,
color: 0xe6e3dc,
colorRanges: ranges,
}),
];
};
/**
* A jolt for each strike (Drei's `CameraShake`, decaying): the strike count is the
* trigger, so every new strike shakes once and settles within a second.
*/
const strikeShake = (count: number) =>
count === 0
? null
: cameraShake({
decay: true,
intensity: 0.8,
decayRate: 1,
maxYaw: 0.012,
maxPitch: 0.02,
maxRoll: 0.015,
yawFrequency: 7,
pitchFrequency: 9,
rollFrequency: 6,
trigger: count,
});
export const sceneFor = (model: Model) =>
scene({
camera: { ...model.camera, shake: strikeShake(strikes(model.rolls)) },
gestures: 'scene',
physics: physics(),
measure: ['scoreboard'],
environment: environment({ environmentIntensity: 0.5 }),
contactShadows: contactShadows({
position: [0, 0.003, -4.4],
size: 3,
far: 0.6,
blur: 1.5,
opacity: 0.5,
}),
render: renderSettings({ shadows: 'soft', toneMapping: 'aces', exposure: 1 }),
children: [
hemisphereLight({ key: 'sky', color: 0xdbe6ff, groundColor: 0x2a2018, intensity: 0.6 }),
directionalLight({
key: 'key',
position: [2, 6, 3],
intensity: 1.2,
castShadow: true,
shadowArea: 14,
}),
spotLight({
key: 'pin-light',
position: [0, 3.2, -3.4],
target: [0, 0, -5.4],
intensity: 30,
angle: 0.6,
penumbra: 0.6,
decay: 2,
castShadow: true,
}),
floor,
...alley,
...laneMarks,
sensors,
...pinsShown(model),
ball(model),
...aimGuide(model),
...scoreboard(model),
],
});
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from './environment';
import { Message } from './message';
import { limits, type Model } from './model';
import { encode, verdict } from './replay';
import { sceneFor } from './scene';
import { frames, score } from './score';
const button = (h: HtmlBuilder<Message>, label: string, message: Message, disabled = false): Html =>
h.button(
[h.Class('e3-button'), h.Type('button'), h.Disabled(disabled), h.OnClick(message)],
[label],
);
const slider = (
h: HtmlBuilder<Message>,
name: string,
value: number,
range: { readonly min: number; readonly max: number },
step: number,
toMessage: (value: number) => Message,
): Html =>
h.label(
[h.Class('e3-note')],
[
`${name} `,
h.input([
h.Class('e3-range'),
h.Type('range'),
h.Min(String(range.min)),
h.Max(String(range.max)),
h.Step(String(step)),
h.Value(String(value)),
h.OnInput((raw) => toMessage(Number.parseFloat(raw) || 0)),
]),
],
);
const status = (model: Model): string => {
const frame = Math.min(10, frames(model.rolls).filter((each) => each.total !== null).length + 1);
const phase = {
racking: `Frame ${frame}. The pins are settling…`,
aiming: `Frame ${frame}. ${model.standing.length} pins standing: aim and roll.`,
rolling: 'Rolling…',
settling: 'Through to the pit; letting the pins settle…',
over: `Game over: ${score(model.rolls)}.`,
}[model.phase];
const impact = model.impact > 0 ? ` Hardest hit ${model.impact.toFixed(0)} N.` : '';
return `${replayStatus(model)}${phase}${impact}`;
};
/** How a replay is going: rolling its record, or how it compared once done. */
const replayStatus = (model: Model): string => {
if (model.replay === null) return '';
const { shots, rolls, rolled } = model.replay;
const check = verdict(rolls, model.rolls);
const progress = `Replaying roll ${Math.min(rolled, shots.length)} of ${shots.length}. `;
if (check._tag === 'Differs') {
return `${progress}Roll ${check.roll} knocked ${check.got}, not the ${check.expected} recorded. `;
}
const done = rolled === shots.length && model.rolls.length >= rolls.length;
return done
? `Replayed ${shots.length} rolls: every one knocked down the same pins as recorded. `
: progress;
};
const replaying = (model: Model): boolean =>
model.replay !== null && model.replay.rolled < model.replay.shots.length;
/** A link that replays this game: its rolls' inputs and outcomes, in the address. */
const shareLink = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> =>
model.shots.length === 0 || model.rolls.length === 0
? []
: [
h.a(
[
h.Class('e3-button'),
h.Href(encode({ shots: model.shots.slice(0, model.rolls.length), rolls: model.rolls })),
],
['Link to this game'],
),
];
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'Bowling — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage e3-stage-tall',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onCollision: (collision) => Message.Collided({ collision }),
onContactForce: ({ totalForceMagnitude }) =>
Message.Struck({ force: totalForceMagnitude }),
onLabelMeasured: (measured) => Message.MeasuredScoreboard({ measured }),
onGesture: (gesture) => Message.MadeGesture({ gesture }),
},
h,
),
h.p([h.Class('e3-readout'), h.AriaLive('polite')], [status(model)]),
h.div(
[h.Class('e3-controls')],
[
slider(h, 'Start', model.aim, limits.aim, 0.02, (aim) => Message.ChangedAim({ aim })),
slider(h, 'Line', model.angle, limits.angle, 0.1, (angle) =>
Message.ChangedAngle({ angle }),
),
slider(h, 'Speed', model.speed, limits.speed, 0.25, (speed) =>
Message.ChangedSpeed({ speed }),
),
],
),
h.div(
[h.Class('e3-controls')],
[
button(h, 'Roll', Message.ClickedRoll(), model.phase !== 'aiming' || replaying(model)),
button(h, 'New game', Message.ClickedNewGame()),
button(h, 'Replay this game', Message.ClickedReplay(), model.shots.length === 0),
...shareLink(model, h),
],
),
h.p(
[h.Class('e3-note')],
[
'The ball and pins are Rapier bodies. Each pin’s spot is a sensor collider named for it, ',
'so a pin’s head leaving its spot says that pin is down; a sensor in the pit says the ball ',
'has gone through, and a Command gives the pins a moment to settle before they are counted. ',
'The scoreboard is a troika label, its panel sized from its measured layout. Each roll ',
'records only what you did; a replay rolls those inputs again from a resting rack, ',
'and the simulation knocks down the same pins (the replay checks it, roll by roll).',
],
),
],
),
});