Game effects: fx.js
game_open puts fx.js in the project root, next to geo.js. It holds a first-load setup (stage), canvas textures (tex), terrain and rule-based placement (field, place, instances), synthesized sound (sfx), particles (burst), camera shake (shake) and metre-scale texture coordinates (uv). Pure JS with no files to load. Nothing in it reads Math.random: the same seed gives the same pixels, points, particles and samples. Never edit it; this page lists its API.
import * as THREE from "three";
import { stage, tex, field, place, instances, sfx, burst, shake, uv } from "./fx.js"; // "../fx.js" from src/ in a vite project
stage
stage(container = document.body, { env, exposure, key, shadow, ground, background, fit }) returns { renderer, scene, camera, key, ground, fit }. It gives ACES tone mapping, sRGB output, a pixel ratio capped at 2, a studio environment, a key light with a shadow, a ground, a camera (a PerspectiveCamera with a 40° vertical fov at (3, 2.5, 5) looking at the origin, near 0.1 and far 1000 until fit) and a resize that follows the container: the setup faults game_look names are already fixed. It starts no render loop.
env: a colour for a flat environment,falsefor none; default the studio box.exposure: tone mapping exposure, default 1.key: key light intensity, default 1.5,0for none.shadow: defaulttrue.ground: default a shadow catcher (shows only shadows); a colour for a solid ground;falseto bring your own.background: default0xe8e8e8;nullis transparent.fit(object or Box3, margin = 1.2)frames the camera on the content from the direction it already looks along, sizes the key light's shadow camera to it and puts the ground under it; it returns the content's centre. Pass the content, not the scene. Call it again when the content grows.
const { renderer, scene, camera, fit } = stage(document.body, { background: 0x1a2030, ground: false, key: 3 });
scene.environmentIntensity = 0.4; // a game's own lit ground: the full studio light washes out the shadows
scene.add(level); fit(level);
renderer.setAnimationLoop(() => renderer.render(scene, camera));
Orbit controls are two lines of your own (the import map needs "three/addons/"):
import { OrbitControls } from "three/addons/controls/OrbitControls.js"; new OrbitControls(camera, renderer.domElement).target.copy(fit(model));
tex
Each builder returns a THREE.CanvasTexture. Colour maps come tagged sRGB; normal is a data map and is not. noise, wood, bricks and windows tile (RepeatWrapping), so set map.repeat or use uv. Colours are anything THREE.Color takes.
tex.noise({ size, scale, colors: [a, b], octaves, seed }): stone, sand, grass, dirt.scaleis blobs across.tex.wood({ size, color, rings, seed }),tex.bricks({ size, rows, cols, brick, mortar, jitter, seed }).tex.windows({ size, cols, rows, lit, frame, glass, wall, seed }): a facade, a seededlitshare of windows warm.tex.label(text, { size, color, background, font, padding }): text sized to its ink;t.image.width / t.image.heightis its aspect.tex.glow(color, { size, falloff, opacity }): a radial blob, for a glow (additive) or a contact shadow (black).tex.dots(text, { cols, rows, on, off, dot }, into): an LED display; pass the last texture asintoto redraw it in place.tex.normal(texture, { strength, invert }): relief from brightness; bricks setinvertfor you.tex.rng(seed): the seeded generator they use, for your own randomness.
const map = tex.bricks({ rows: 8, cols: 4 }); map.repeat.set(4, 2);
wall.material = new THREE.MeshStandardMaterial({ map, normalMap: tex.normal(map) });
field, place, instances
field(size, { seed, octaves, height, scale, base, basins: [[x, z, radius, depth]] })is a height function centred on the origin:{ area, heightAt(x, z), normalAt(x, z), geometry(segments) }. Build the mesh withgeometry(segments)before placing things, soheightAtfollows the drawn triangles.place(n, options)scattersnrandom points that pass every rule (fewer, with a console error, when they do not fit);place([nx, nz], options)is a grid spanningareacorner to corner. Options:area: [x0, z0, x1, z1],seed,on(a field),minHeight,maxHeight,maxSlope(degrees),avoid(x, z),spacing,jitter(grid),edge(grid border only),scale: [min, max],rotate(true or a yaw),tilt(degrees),stretch. Each point is{ x, y, z, ry, s, rx, rz, sx, sz }.instances(geometry, material, points, name)turns the points into one namedInstancedMesh.
const land = field(100, { seed: 2, height: 5, basins: [[8, 6, 12, 7]] }); // a lake 7 deep at (8, 6)
scene.add(new THREE.Mesh(land.geometry(160), grassMat));
scene.add(instances(treeGeo, treeMat, place(150, { on: land, seed: 3, minHeight: 0, maxSlope: 30, scale: [0.8, 1.6], rotate: true }), "trees"));
sfx
Sound effects synthesized in code (a port of ZzFX), no audio files. Presets: coin, jump, hit, explode, shoot, laser, powerup, blip, bounce, thud, roll.
sfx.play(sound, volume = 1, when = 0): a preset name or a params object,whenseconds from now (a note sequence needs nosetTimeout). Returns the source node (setloop,playbackRate, callstop()), orundefinedwhen audio is locked or missing. It never throws for blocked audio; an unknown preset name throws.sfx.define(name, params)adds a preset. Params:volume,frequency,attack,sustain,release,shape(0 sine, 1 triangle, 2 saw, 3 tan, 4 noisy sine, 5 square),slide,pitchJump,pitchJumpTime,repeatTime,noise,filter(negative low-pass),decay,sustainVolume,delay,bitCrush,tremolo,seedand more.sfx.check(sound)returns{ duration, peak, rms, clipped, hz }: hear a sound throughgame_eval(return (await import("./fx.js")).sfx.check("door"):sfxis not in its scope).peakover 1 clips; noise reads as a highhz.sfx.logholds every play call{ name, time, audible };sfx.presets()lists the names;sfx.render(sound)gives the samples.
sfx.define("door", { frequency: 90, shape: 2, release: 0.3, slide: -1 }); sfx.play("door");
const r = sfx.play("roll"); if (r) { r.loop = true; r.playbackRate.value = speed; } // a looping voice needs release: 0, as roll has
burst and shake
burst({ max = 500, seed = 1, size = 0.1, additive = true, ...defaults }) returns a Burst: one THREE.Points pool of soft round dots, positions in its own space. The other options are defaults for emit.
emit(at, { count, speed, life, gravity, drag, color, spread, up })starts particles at aVector3. Defaults: 20, 4 units a second, 1 s, 9.8 along -y (a number, or[x, y, z]for a z-up game), no drag, white,spreadMath.PI (a sphere; it is the cone's half-angle aroundup). Pastmax, the rest are dropped; speed and life vary by up to half.update(dt)moves, ages and fades every particle; call it once per frame.aliveis the live count.shake(object, { seed = 1, decay = 6 })returns{ kick(strength), update(dt) }: a seeded, decaying shake for a camera or any object. Callupdateafter placing the object each frame.
const sparks = burst({ max: 300, seed: 1, size: 0.1 }); sparks.name = "sparks"; scene.add(sparks);
sparks.emit(hit.point, { count: 40, speed: 6, spread: Math.PI / 4, color: 0xffaa33 });
const cam = shake(camera); cam.kick(0.3);
// each frame, after placing the camera: sparks.update(dt); cam.update(dt);
uv
uv(geometry, metresPerRepeat = 1, offset = 0) writes box-projected texture coordinates in the geometry's own units, so one tiling texture shows at the same real size on a floor, a wall, terrain and geo.js's boolean output (which has no UVs). It returns the geometry; call it before the first render. offset shifts u by that many metres, so buildings sharing a tex.windows tile do not show the same lit windows.
floor.geometry = uv(new THREE.BoxGeometry(6, 0.1, 5), 2); // tex.wood repeats every 2 m
Pitfalls
- Name every burst and instanced mesh, so game_look lists it. A burst with no live particles shows as
Never visible: sparks (0 units, no geometry)until it emits. - Before the page's first click or key, audio is locked:
playlogsaudible: falseand plays nothing. Readsfx.logorsfx.check, not the speakers. - Give each builder its own fixed
seed: two with the same seed draw the same pattern. uvworks in the geometry's units: amesh.scalestretches the metres.