import * as THREE from 'three'
import './ui/styles.css'
import { DEFAULT_PRESET, Settings, hasStoredSettings } from './core/settings.js'
import { Engine } from './core/engine.js'
import { CameraRig } from './core/camera.js'
import { Colony, STATUS_LABEL, STATUS_ORDER, statusFor, transcriptProgress } from './game/colony.js'
import { Hud } from './ui/hud.js'
import { PLANETS } from './world/planet.js'
import { DECK_TOP, PLOT_CELL, hexToWorld, worldToHex } from './world/plots.js'
import { planMove } from './world/plot-move.js'
import { loadKit } from './world/kit.js'
import { crewRig, loadCrew } from './agents/crew.js'
import { TIMES } from './world/sky.js'
import { CURVE_FULL, bendPoint, installWorldCurve, setCurveView } from './core/curve.js'
import { Ambience } from './audio/ambience.js'
import { shorelinePoints } from './world/planet.js'
import { shipPosition } from './world/plots.js'
import {
fetchThreads,
fetchState,
saveState,
openThread,
newSession,
revealFolder,
} from './game/api.js'
import { hideProject, hiddenCatalog, unhideProject } from './game/hidden-projects.js'
import { withErrands } from './game/errands.js'
/**
* Boot and the outer game loop.
*
* The one interesting piece of orchestration here is the archive round trip. The harness
* owns the session records; the colony owns nothing but its own list of what you archived,
* and that list is written by exactly one writer — this page — so a save from a stale tab
* can never silently drop an archive. Everything else is wiring.
*/
const POLL_MS = 15000
const app = document.getElementById('app')
app.insertAdjacentHTML(
'beforeend',
`
Bot Crossing
Scanning for agent threads…
`
)
const settings = new Settings()
// A phone gets the light preset the first time: a retina panel at full scale with bloom
// and shadows is more than its GPU wants to do at sixty, and the governor only ever finds
// that out by stuttering first.
const phoneLike = window.matchMedia('(max-width: 600px)').matches || (window.matchMedia('(pointer: coarse)').matches && window.innerWidth < 900)
if (!hasStoredSettings()) settings.applyPreset(phoneLike ? 'low' : DEFAULT_PRESET)
// Before the first material compiles: the bend is patched into three's own shader chunks.
installWorldCurve()
const engine = new Engine(settings).mount(app)
engine.setPlanetGrade(PLANETS[settings.get('planet')]?.grade)
const rig = new CameraRig(engine.camera, engine.canvas, settings)
const colony = new Colony(engine.scene, settings, engine.camera, engine.renderer)
let state = { archived: [], archivedAt: {}, opened: [], plots: {}, seen: {}, hiddenProjects: [], viewedAt: {} }
let threads = []
/** Last legend built for the bottom bar, kept so the open zone's chip can light up between polls. */
let legendProjects = []
/** The zone layout as last written to the colony file, so an unchanged map is not re-saved. */
let lastLayout = ''
let selectedId = null
/** Which zone's sidebar is open. A repo, not a thread — they outlive the threads on them. */
let selectedProject = null
let hoverId = null
let statusCursor = 0
let pendingSave = 0
const hoverGround = new THREE.Vector3()
// ── actions the HUD can trigger ────────────────────────────────────────────────────────
const actions = {
viewportChanged: ({ width, height, right, bottom }) => {
rig.setViewportInsets(width, height, { right, bottom })
engine.tiltShift?.setCamera(engine.camera)
},
resetView: () => {
if (rig.following) select(null, {})
rig.resetView()
},
screenshot: () => {
// Render one more frame, then read the buffer before the compositor clears it — the
// alternative is preserveDrawingBuffer, which costs a copy on every single frame.
engine.renderFrame()
const url = engine.canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = `bot-crossing-${colony.planet.id}-${stamp()}.png`
a.click()
hud.toast('Screenshot saved')
},
/** Google Earth's auto-rotate: a slow sweep around whatever is centred. */
toggleOrbit: () => {
const on = rig.toggleOrbit()
hud.hint(on ? 'Orbit mode on — drag or press O to stop' : 'Orbit mode off')
return on
},
cyclePlanet: () => {
const ids = Object.keys(PLANETS)
const next = ids[(ids.indexOf(settings.get('planet')) + 1) % ids.length]
settings.set('planet', next)
hud.hint(`${PLANETS[next].name} — ${PLANETS[next].blurb}`)
},
cycleTime: () => {
settings.set('autoTime', false)
settings.set('clockTime', false)
const current = settings.get('timeOfDay')
// Step to the next named time *after* the current one, wrapping at midnight.
const next = TIMES.find((t) => t.value > current + 0.005) || TIMES[0]
settings.set('timeOfDay', next.value)
hud.hint(next.label)
},
/** Fly to the next astronaut in a given state, cycling through them on repeat presses. */
focusStatus: (status) => {
const key = status === 'agents' ? null : status
const pool = colony.astronauts.agents.filter((a) => (key ? a.status === key : true))
if (!pool.length) {
hud.hint(key ? `Nobody is ${(STATUS_LABEL[key] || key).toLowerCase()} right now` : 'No bots on the surface')
return
}
pool.sort((a, b) => a.id.localeCompare(b.id))
const agent = pool[statusCursor++ % pool.length]
select(agent.id, { fly: true })
},
focusProject: (name) => {
const plot = colony.plots.get(name)
if (!plot) return
if (rig.following) select(null, {})
rig.focus(plot.middle || plot.center, { distance: 30 })
},
/** The legend, and anything else that means "show me this repo". */
pickProject: (name) => selectProject(name, { fly: true }),
/** Back out of one repo to the list of all of them. The panel itself never leaves. */
closeProject: () => {
selectedProject = null
select(null, {})
syncProject()
},
select: (id) => select(id, {}),
focusThread: (id) => select(id, { fly: true }),
/**
* A new thread in this repo. The desktop app opens an empty session with the folder as
* its workspace — nothing here is resumed, and nothing is written to disk.
*/
newConversation: async () => {
const name = selectedProject
const folder = name && pathForProject(name)
if (!folder) {
hud.toast('No folder on disk for that project', 'err')
return
}
try {
const harness = harnessForProject(name)
const shown = await newSession(folder, harness, settings.get('openIn'))
const label = harnessLabel(harness)
hud.toast(`New thread in ${name} — ${shown.via === 'terminal' ? `${label} in a terminal` : `opening ${label}`}`)
// It lands as an astronaut walking down the ramp, once it has a record to scan.
setTimeout(poll, 6000)
} catch (err) {
hud.toast(err.message || 'Could not start a thread there', 'err')
}
},
revealProject: async () => {
const folder = selectedProject && pathForProject(selectedProject)
if (!folder) return
try {
await revealFolder(folder)
} catch (err) {
hud.toast(err.message || 'Could not open that folder', 'err')
}
},
/**
* Stop a thread asking for you, without touching it.
*
* `unread` comes from the harness, and the harness only counts a thread as read when it is
* focused *in its own app*. Answer one in a terminal, or read it over somebody's shoulder,
* and it keeps its hand up forever. Marking it viewed here records when you looked; the
* moment the thread does something newer than that it goes back to waving, which is the
* behaviour you actually want and the reason this is a timestamp rather than a flag.
*/
markViewed: () => {
const thread = threads.find((t) => t.id === selectedId)
if (!thread) return
state.viewedAt = { ...(state.viewedAt || {}), [thread.id]: Date.now() }
queueSave()
applyThreads(threads)
hud.toast(`Marked ${thread.title.slice(0, 40)} as viewed`)
},
hideProject: () => {
const name = selectedProject
if (!name) return
state.hiddenProjects = hideProject(state.hiddenProjects || [], name)
queueSave()
// If the open thread belonged to the repo that just left, nothing is selected any more.
if (selectedId) {
const thread = threads.find((t) => t.id === selectedId)
if (thread?.project === name) select(null, {})
}
selectedProject = null
applyThreads(threads)
hud.toast(`Hidden ${name} — still in your harness, gone from the colony`)
},
unhideProject: (name) => {
if (!name) return
state.hiddenProjects = unhideProject(state.hiddenProjects || [], name)
queueSave()
applyThreads(threads)
hud.toast(`Showing ${name} again`)
},
copyProjectPath: async () => {
const folder = selectedProject && pathForProject(selectedProject)
if (!folder) return
try {
await navigator.clipboard.writeText(folder)
hud.toast('Path copied')
} catch {
// The async clipboard needs a permission this page does not always have — inside an
// embedded preview, say. The old selection-based copy has no such gate.
const copied = copyFallback(folder)
hud.toast(copied ? 'Path copied' : 'Could not reach the clipboard', copied ? '' : 'err')
}
},
openThread: async () => {
const thread = threads.find((t) => t.id === selectedId)
if (!thread) return
try {
const shown = await openThread(thread, settings.get('openIn'))
colony.astronauts.celebrate(thread.id)
const name = thread.harnessName || 'your harness'
hud.toast(shown.via === 'terminal' ? `Opened ${name} in a terminal` : `Opened in ${name}`)
// Opening is the thing that makes a thread no longer unread, so refresh shortly after.
setTimeout(poll, 1800)
} catch (err) {
hud.toast(err.message || 'Could not open that thread', 'err')
}
},
// Archiving is the colony's own bookkeeping and nothing else: the thread leaves the map and
// the astronaut walks back to the ship. The harness's own records are never touched — see
// `reconcileArchived` in server/api.mjs for why that stopped being worth doing.
archiveThread: () => {
const thread = threads.find((t) => t.id === selectedId)
if (!thread) return
const foldedBefore = new Set(colony.dormantProjects || [])
state.archived = [...new Set([...state.archived, thread.id])]
state.archivedAt = { ...state.archivedAt, [thread.id]: Date.now() }
queueSave()
select(null, {})
applyThreads(threads)
// Retiring the last thread anybody has touched in a repo makes every thread left in it
// dormant, and the whole zone folds away — sixty astronauts can leave the map on one
// click. That is the setting working, but silently it reads as the colony breaking, so
// it says which repo went and why.
const folded = [...(colony.dormantProjects || [])].filter((n) => !foldedBefore.has(n))
hud.toast(
folded.length
? `Archived — ${folded.join(', ')} ${folded.length === 1 ? 'is' : 'are'} all quiet now, folded off the map`
: 'Archived — heading home'
)
colony.ship.ping()
},
uiVisibility: (visible) => colony.setUiVisible(visible),
// The card's bar is about the *thread*, not about how much of its building has risen —
// those were the same number while construction was drawn by burying the structure.
progressFor: (id) => {
const thread = threads.find((t) => t.id === id)
return thread ? transcriptProgress(thread) : 0
},
}
/**
* Sound. Beds per world, things calling out on their own clocks, and positional sources
* for whatever is actually making noise — a site being hammered at, the lander, a drone
* going past — attenuated by distance from the camera, so leaning in is turning it up.
*/
const ambience = new Ambience(settings)
ambience.setPlanet(colony.planet)
colony.onSound = (name, x, y, z) => ambience.play(name, { x, y, z, kind: colony.fauna.flock?.kind })
const hud = new Hud(app, settings, actions)
// ── selection ─────────────────────────────────────────────────────────────────────────
let lastVoiced = null
let lastPhrase = 0
function select(id, { fly = false } = {}) {
selectedId = id
const agent = id ? colony.agentFor(id) : null
if (!agent) {
selectedId = null
rig.setFollow(null)
colony.astronauts.setSelected(null)
hud.setSelection(null, null)
syncProject()
return
}
colony.astronauts.setSelected(agent)
const thread = threads.find((t) => t.id === id) || agent.thread
hud.setSelection(agent, thread)
// It answers. One of six little phrases, from where it is standing, never twice in a row.
if (agent.id !== lastVoiced) {
lastVoiced = agent.id
let n = 1 + Math.floor(Math.random() * 6)
if (n === lastPhrase) n = (n % 6) + 1
lastPhrase = n
ambience.play(`select-${n}`, { x: agent.pos.x, y: agent.pos.y + 0.8, z: agent.pos.z, gain: 0.9 })
}
// Picking somebody is also picking the zone they are standing on: the sidebar follows.
if (thread?.project && colony.plots.has(thread.project)) selectedProject = thread.project
syncProject()
if (fly) {
rig.focus(new THREE.Vector3(agent.pos.x, 0, agent.pos.z), { distance: Math.min(rig.desiredDistance, 26) })
}
rig.setFollow(settings.get('followSelected') ? agent : null)
}
/** Open a zone's sidebar. Any selected astronaut from a different zone lets go. */
function selectProject(name, { fly = false } = {}) {
if (!name || !colony.plots.has(name)) return
selectedProject = name
const current = threads.find((t) => t.id === selectedId)
if (current && current.project !== name) select(null, {})
else syncProject()
if (fly) actions.focusProject(name)
}
/**
* The repo folder behind a zone. Plots are keyed by the folder's *name*, which is all the
* colony needs to draw one — the path itself lives on the threads, so it is read back off
* them, taking the most common answer if two checkouts somehow share a basename.
*/
/** The human name for a harness id — every thread already carries its own. */
function harnessLabel(id) {
for (const thread of colony.threads.values()) {
if (thread.harness === id && thread.harnessName) return thread.harnessName
}
return 'your harness'
}
/**
* Which harness a project's threads belong to, picked the same way its path is: the most
* common answer among the threads standing there. A repo worked on from two harnesses gets
* a new thread in whichever one it is mostly used from.
*/
function harnessForProject(name) {
const counts = new Map()
for (const thread of colony.threads.values()) {
if (thread.project !== name || !thread.harness) continue
counts.set(thread.harness, (counts.get(thread.harness) ?? 0) + 1)
}
let best = ''
let bestCount = 0
for (const [id, n] of counts) {
if (n <= bestCount) continue
best = id
bestCount = n
}
return best
}
function pathForProject(name) {
const counts = new Map()
for (const thread of colony.threads.values()) {
if (thread.project !== name) continue
const dir = thread.projectPath || thread.cwd
if (!dir) continue
counts.set(dir, (counts.get(dir) ?? 0) + 1)
}
let best = ''
let bestCount = 0
for (const [dir, n] of counts) {
if (n <= bestCount) continue
best = dir
bestCount = n
}
return best
}
/** Push the open zone's current contents at the sidebar. Closes it if the zone is gone. */
function syncProject() {
const hidden = hiddenCatalog(state.hiddenProjects || [], threads)
// Folded-away repos are listed alongside the ones you hid by hand. Same principle: nothing
// leaves the map without somewhere on screen saying where it went.
const folded = hiddenCatalog([...(colony.dormantProjects || [])], threads)
const plot = selectedProject ? colony.plots.get(selectedProject) : null
if (!plot) {
selectedProject = null
hud.setProject(null)
hud.setLegend(legendProjects, null, hidden, folded)
return
}
const now = Date.now()
const list = [...colony.threads.values()]
.filter((thread) => thread.project === plot.name)
.map((thread) => ({
id: thread.id,
title: thread.title,
worktree: thread.worktree,
lastActivityAt: thread.lastActivityAt,
status: statusFor(thread, now),
}))
// Whoever wants something first, then most recently touched — the same order of
// importance the badges use above their heads.
.sort((a, b) => {
const rank = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)
return rank || (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)
})
hud.setProject({
name: plot.name,
accent: plot.accent,
path: pathForProject(plot.name),
threads: list,
selectedId,
})
// The legend is the same selection seen from the bottom of the screen: keep it in step
// here rather than only on the next poll.
hud.setLegend(legendProjects, selectedProject, hidden, folded)
}
// ── pointer ───────────────────────────────────────────────────────────────────────────
/**
* Where an astronaut is on screen, in CSS pixels, or null if it is behind the camera.
*
* Measured off the engine's own viewport rather than the canvas's bounding rect: this runs
* every frame for the selected agent, and a layout read per frame to learn a number that
* only changes on resize is the kind of thing that quietly costs a HUD its smoothness.
*/
const cardAnchor = new THREE.Vector3()
function screenOf(agent) {
bendPoint(cardAnchor.set(agent.pos.x, agent.pos.y + 0.95, agent.pos.z)).project(engine.camera)
if (cardAnchor.z > 1) return null
const { w, h } = engine.viewport
return { x: (cardAnchor.x * 0.5 + 0.5) * w, y: (-cardAnchor.y * 0.5 + 0.5) * h }
}
function ndc(e) {
const rect = engine.canvas.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
y: -((e.clientY - rect.top) / rect.height) * 2 + 1,
aspect: rect.width / rect.height,
}
}
engine.canvas.addEventListener('pointermove', (e) => {
// Mid-drag the cursor is the grab hand and nothing else: running a pick every move event
// while the world is being dragged would flicker the hover ring across the whole colony.
if (rig.interacting) {
engine.canvas.style.cursor = rig._mode === 'orbit' ? 'move' : 'grabbing'
return
}
const p = ndc(e)
const agent = colony.pick(p.x, p.y, p.aspect)
hoverId = agent?.id ?? null
colony.astronauts.setHover(agent)
// Pointing at a quiet plot is what makes its name appear.
const plot = plotUnder(e, p)
colony.setHoveredPlot(plot)
// A plot is grabbable as well as clickable, so it gets the hand rather than the finger:
// 'pointer' promised only a click and hid the hold-to-drag entirely.
engine.canvas.style.cursor = agent ? 'pointer' : 'grab'
})
/**
* The zone under the cursor: its name plate first, then the deck itself. The plate is
* hit-tested whether or not it is currently faded in — pointing at where a quiet project's
* name would be is exactly what makes it appear.
*/
function plotUnder(e, p) {
const label = colony.pickLabel(p.x, p.y)
if (label) return label
const ground = rig.groundPoint(e.clientX, e.clientY, hoverGround)
return ground ? colony.plotAt(ground.x, ground.z) : null
}
// ── plot dragging ─────────────────────────────────────────────────────────────────────
/**
* Hold-to-lift, on the same press that would otherwise pan. The two gestures share a
* button, and the split is time: move within the hold and it was a pan all along, keep
* still and the plot under the pointer picks up. Everything mid-carry is cosmetic — a
* y-offset and a ghost of the footprint — and the real move is a single `movePlot` plus
* one roster pass on the drop, so a cancelled drag has nothing to unwind but visuals.
*/
const HOLD_MS = 250
/** How high a carried zone floats. Enough to read as "picked up", not enough to occlude. */
const LIFT_Y = 1.1
/**
* The carried zone's colours, taken from the palette the rest of the colony already uses —
* `working` green and the soft red the HUD reds a thread with, rather than a saturated pair
* mixed for this one job. A drag is a normal thing to do and should not look like an alarm.
*/
const GHOST_VALID = 0x7fd39a
const GHOST_INVALID = 0xe88b8b
/** The rim is the same hue lifted toward white, so the tile has an edge without a second colour. */
const GHOST_VALID_RIM = 0xcdf3de
const GHOST_INVALID_RIM = 0xffcfcf
const drag = {
timer: 0, // pending long-press
candidate: null, // repo name under the pressed pointer
startX: 0,
startY: 0,
lifted: false,
name: null,
cells: null, // the zone's footprint at lift, root first
grab: null, // which lattice cell the press landed on — the drag is relative to it
dq: 0,
dr: 0,
valid: true,
plan: null, // the layout a drop would apply, from planMove — null while the drop is illegal
swallowClick: false,
ghost: null, // { group, meshes, material, geometry }
pendingThreads: null, // a poll that landed mid-carry, applied on the drop
}
const dragGround = new THREE.Vector3()
/**
* A rounded hexagon, flat in XZ and in phase with the lattice.
*
* Built as a `Shape` rather than a six-sided cylinder because the corners are the whole point:
* a hard hex prism reads as a selection box, and the colony is drawn in soft shapes everywhere
* else. Corners are quadratic arcs through the true vertex, which keeps the flat-to-flat width
* exactly `radius * √3` — the tile still lines up with its neighbours, it has just lost its
* points.
*
* Vertex angles match `CylinderGeometry(…, 6)` after `rotateY(π/6)`: measured from +Z toward +X,
* every 60° starting at 30°. `rotateX(-π/2)` flips the shape's y into −z, which for a hexagon at
* those angles is the same hexagon, so the phase survives the lay-down.
*/
function roundedHexGeometry(radius, round) {
const corner = (k) => {
const a = Math.PI / 6 + (k * Math.PI) / 3
return new THREE.Vector2(radius * Math.sin(a), radius * Math.cos(a))
}
const towards = (from, to, d) => from.clone().lerp(to, Math.min(d / from.distanceTo(to), 0.5))
const shape = new THREE.Shape()
for (let k = 0; k < 6; k++) {
const prev = corner((k + 5) % 6)
const here = corner(k)
const next = corner((k + 1) % 6)
const inbound = towards(here, prev, round)
const outbound = towards(here, next, round)
if (k === 0) shape.moveTo(inbound.x, inbound.y)
else shape.lineTo(inbound.x, inbound.y)
shape.quadraticCurveTo(here.x, here.y, outbound.x, outbound.y)
}
shape.closePath()
const geo = new THREE.ShapeGeometry(shape, 6)
geo.rotateX(-Math.PI / 2)
return geo
}
/**
* One tile per cell of the carried zone: a soft fill with a brighter rim sitting a hair above it.
*
* Two meshes rather than one because an unlit fill on its own has no edge, and over pale ground
* — sand, snow — a 30%-opacity wash simply disappears. The rim is what makes the footprint
* legible on every world, and it is the same geometry inset, so it costs one more draw and no
* new shape. Normal blending, not additive: additive over bright ground blows out to white and
* turns a routine drag into something that looks like an error state.
*/
function buildGhost(count) {
// Corner radius is a tenth of the tile, not a third: enough to take the points off so the
// footprint sits with the rest of the art, not so much that a hexagon reads as a circle.
// The rim is a line, not a border — 0.04 of a cell, about a third of a unit on the ground.
const geometry = roundedHexGeometry(PLOT_CELL * 0.94, PLOT_CELL * 0.1)
const inner = roundedHexGeometry(PLOT_CELL * 0.9, PLOT_CELL * 0.096)
const material = new THREE.MeshBasicMaterial({
color: GHOST_VALID,
transparent: true,
opacity: 0.38,
depthWrite: false,
})
const rim = new THREE.MeshBasicMaterial({
color: GHOST_VALID_RIM,
transparent: true,
opacity: 0.85,
depthWrite: false,
})
const group = new THREE.Group()
const meshes = []
for (let i = 0; i < count; i++) {
// The rim is the outer tile; the fill sits just inside it and just above, so the two never
// z-fight on a deck that is itself a flat plane.
const mesh = new THREE.Mesh(geometry, rim)
const fill = new THREE.Mesh(inner, material)
fill.position.y = 0.01
mesh.add(fill)
meshes.push(mesh)
group.add(mesh)
}
engine.scene.add(group)
drag.ghost = { group, meshes, material, rim, geometry, inner }
}
function placeGhost() {
const { meshes } = drag.ghost
drag.cells.forEach((c, i) => {
const { x, z } = hexToWorld(c.q + drag.dq, c.r + drag.dr)
// Just proud of the deck, which is itself proud of the roughest terrain.
meshes[i].position.set(x, DECK_TOP + 0.12, z)
})
drag.ghost.material.color.setHex(drag.valid ? GHOST_VALID : GHOST_INVALID)
drag.ghost.rim.color.setHex(drag.valid ? GHOST_VALID_RIM : GHOST_INVALID_RIM)
}
function disposeGhost() {
if (!drag.ghost) return
engine.scene.remove(drag.ghost.group)
drag.ghost.geometry.dispose()
drag.ghost.inner.dispose()
drag.ghost.material.dispose()
drag.ghost.rim.dispose()
drag.ghost = null
}
function cancelHold() {
clearTimeout(drag.timer)
drag.timer = 0
drag.candidate = null
}
function liftPlot() {
drag.timer = 0
const plot = colony.plots.get(drag.candidate)
drag.candidate = null
if (!plot) return // a poll rebuilt it out from under the hold — rare, and a lift of nothing
drag.lifted = true
drag.name = plot.name
drag.cells = plot.cells
// The drag is relative to the cell the press landed on, not to the zone's root: snapping
// the root under a cursor that grabbed the far corner would jump the zone half its own
// width on the first pixel of movement.
const g = rig.groundPoint(drag.startX, drag.startY, dragGround)
drag.grab = g ? worldToHex(g.x, g.z) : { ...plot.cells[0] }
drag.dq = 0
drag.dr = 0
drag.valid = true
// The pan gesture is already live under this press; `suppressed` is its escape hatch, and
// it self-clears on pointerup, so the rest of the press belongs to carrying the plot.
rig.suppressed = true
colony.setPlotLift(drag.name, LIFT_Y)
buildGhost(drag.cells.length)
placeGhost()
engine.canvas.style.cursor = 'grabbing'
}
/**
* Put the drag down, applying the move or not. Either way this ends in the standard
* "the map changed under the same roster" re-entry: `applyThreads` re-runs the roster pass,
* whose signature diff rebuilds the moved plot on its new ground, recomputes navigation and
* every crew site so the astronauts walk over rather than hammering at bare dirt, and whose
* layout diff writes the move to the colony file.
*/
function settleDrag(apply) {
colony.setPlotLift(drag.name, 0)
disposeGhost()
if (apply && drag.plan) colony.applyLayout(drag.plan)
const pending = drag.pendingThreads
drag.lifted = false
drag.pendingThreads = null
drag.name = null
drag.cells = null
drag.grab = null
drag.plan = null
if (apply || pending) applyThreads(pending || threads)
engine.canvas.style.cursor = 'grab'
}
engine.canvas.addEventListener('pointerdown', (e) => {
if (drag.timer) cancelHold()
if (drag.lifted) {
// A second finger mid-carry is the start of a pinch, not a drop: put the zone back.
settleDrag(false)
drag.swallowClick = true
return
}
// The camera reads these modifiers as "tilt and rotate" — that press is never a lift.
if (e.button !== 0 || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return
const p = ndc(e)
if (colony.pick(p.x, p.y, p.aspect)) return // a press on an astronaut is a selection
const plot = plotUnder(e, p)
if (!plot) return
drag.candidate = plot.name
drag.startX = e.clientX
drag.startY = e.clientY
drag.timer = setTimeout(liftPlot, HOLD_MS)
})
// On window, like the camera's own listeners: a carry does not end at the canvas edge.
window.addEventListener('pointermove', (e) => {
// The same 6px the camera's `wasClick` uses: past it this press was a pan all along.
if (drag.timer && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 6) cancelHold()
if (!drag.lifted) return
if (!rig.groundPoint(e.clientX, e.clientY, dragGround)) return
const cell = worldToHex(dragGround.x, dragGround.z)
const dq = cell.q - drag.grab.q
const dr = cell.r - drag.grab.r
if (dq === drag.dq && dr === drag.dr) return
drag.dq = dq
drag.dr = dr
// The plan, not just a yes/no: a drop that strands a zone is allowed, and what comes back
// says where everything ends up. Kept so the drop applies exactly what the ghost was drawn
// against rather than recomputing against a layout a poll may have moved on.
drag.plan = planMove(colony.visibleLayout(), drag.name, dq, dr)
drag.valid = Boolean(drag.plan)
placeGhost()
})
window.addEventListener('pointerup', () => {
if (drag.timer) cancelHold()
if (drag.lifted) settleDrag(drag.valid && (drag.dq !== 0 || drag.dr !== 0))
// Cleared after the canvas's own pointerup has run — bubbling order is what lets the
// click handler still see it.
drag.swallowClick = false
})
window.addEventListener('pointercancel', () => {
if (drag.timer) cancelHold()
if (drag.lifted) settleDrag(false)
})
// Pressing on an astronaut used to suppress the camera, on the theory that grabbing one
// should not also drag the world out from under it. But nothing is draggable *about* an
// astronaut — a press is only ever the start of a selection or the start of a pan — so all
// that suppression did was make the ground refuse to move whenever a drag happened to begin
// on top of somebody. Selection is decided on release instead, where `wasClick` already
// distinguishes a click from a drag.
engine.canvas.addEventListener('pointerup', (e) => {
// A release that ends a lift is the end of a carry, not a click — even an unmoved one:
// long-pressing a plot and thinking better of it should not also open its sidebar.
if (e.button !== 0 || !rig.wasClick || drag.lifted || drag.swallowClick) return
const p = ndc(e)
const agent = colony.pick(p.x, p.y, p.aspect)
if (agent) {
select(agent.id, {})
return
}
// Nobody there: whoever was selected is put down first, whatever else the click lands on
// — a deck of the same repo used to keep the card up. Then a zone's deck or its name plate
// opens that repo's sidebar, and bare ground closes that too.
if (selectedId) select(null, {})
const plot = plotUnder(e, p)
if (plot) selectProject(plot.name, {})
else actions.closeProject()
})
engine.canvas.addEventListener('pointerleave', () => {
hoverId = null
colony.astronauts.setHover(null)
colony.setHoveredPlot(null)
})
// ── keyboard ──────────────────────────────────────────────────────────────────────────
window.addEventListener('keydown', (e) => {
// Never steal keys from a field the user is actually typing in.
const t = e.target
if (t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement) return
// ⌘\ (⌃\ elsewhere) dismisses the chrome, the same as H — the shortcut every editor
// uses for its sidebar, and the one hand that is already on the keyboard.
if ((e.metaKey || e.ctrlKey) && e.key === '\\') {
e.preventDefault()
hud.toggleUi()
return
}
if (e.metaKey || e.ctrlKey || e.altKey) return
switch (e.key) {
case 'h':
case 'H':
hud.toggleUi()
break
case 's':
case 'S':
hud.toggleSettings()
break
case 'n':
case 'N':
actions.focusStatus('waiting')
break
case 'p':
case 'P':
actions.screenshot()
break
case 'l':
case 'L':
actions.cycleTime()
break
case 'o':
case 'O':
hud.setOrbit(actions.toggleOrbit())
break
case 'm':
case 'M':
settings.set('sound', !settings.get('sound'))
hud.hint(settings.get('sound') ? 'Sound on' : 'Muted')
break
case 'Tab':
e.preventDefault()
actions.cyclePlanet()
break
case '0':
actions.resetView()
hud.setOrbit(false)
break
case 'Enter':
if (selectedId) actions.openThread()
break
case 'a':
case 'A':
if (selectedId) actions.archiveThread()
break
case 'v':
case 'V':
if (selectedId) actions.markViewed()
break
case 'c':
case 'C':
if (selectedProject) actions.newConversation()
break
case '?':
hud.toggleHelp()
break
// Arrow keys nudge the view and +/- zoom, the same as Earth's keyboard.
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight': {
e.preventDefault()
const step = rig.distance * 0.09
const forward = new THREE.Vector3(Math.sin(rig.azimuth), 0, Math.cos(rig.azimuth))
const right = new THREE.Vector3(forward.z, 0, -forward.x)
if (e.key === 'ArrowUp') rig.desiredTarget.addScaledVector(forward, -step)
if (e.key === 'ArrowDown') rig.desiredTarget.addScaledVector(forward, step)
if (e.key === 'ArrowLeft') rig.desiredTarget.addScaledVector(right, -step)
if (e.key === 'ArrowRight') rig.desiredTarget.addScaledVector(right, step)
rig._clampTarget()
rig.idleFor = 0
break
}
case '+':
case '=':
rig.desiredDistance = Math.max(4, rig.desiredDistance * 0.82)
break
case '-':
case '_':
rig.desiredDistance = Math.min(150, rig.desiredDistance * 1.22)
break
// One step at a time, outward: the drag in hand, the thread, then the zone.
case 'Escape':
if (drag.lifted || drag.timer) {
cancelHold()
if (drag.lifted) {
settleDrag(false)
// The pointer is still down; the release that follows ends a dead gesture.
drag.swallowClick = true
}
} else if (document.querySelector('.help.open')) hud.toggleHelp(false)
else if (selectedId) select(null, {})
else if (selectedProject) actions.closeProject()
break
}
})
// ── data ──────────────────────────────────────────────────────────────────────────────
function applyThreads(list) {
// Parked while a plot is in hand. `allocateCells` would leave the carried zone's cells
// alone, but a sibling that grew a thread still rebuilds — and any rebuild pass disposes
// whichever plots changed, which mid-carry means the lifted group can be torn down under
// the drag's own hands. Polls are 15s apart and a drag is seconds; the scan waits.
if (drag.lifted) {
drag.pendingThreads = list
return
}
list = withErrands(list)
// A thread you have said you looked at stops counting as unread until it moves on again.
// Done here rather than in `statusFor` so the card, the badge and the astronaut all agree.
const viewed = state.viewedAt || {}
threads = list.map((t) => {
const at = viewed[t.id]
return at && t.lastActivityAt <= at ? { ...t, unread: false } : t
})
list = threads
const archivedSet = new Set(state.archived)
const hiddenSet = new Set(state.hiddenProjects || [])
// Which threads the colony has met before. Walking out of the ship is meant to *mean*
// something — a thread that just appeared — and without this every reload staged a
// hundred-astronaut entrance, which piled up at the ramp and read as a bug because it was
// one. A thread already on the books is simply already outside.
const known = new Set(Object.keys(state.seen || {}))
let firstSeen = false
for (const t of list) {
if (state.seen?.[t.id]) continue
state.seen = { ...(state.seen || {}), [t.id]: Date.now() }
firstSeen = true
}
if (firstSeen) queueSave()
const stats = colony.setThreads(list, archivedSet, hiddenSet, known)
hud.setStats(stats)
chimeForNewWaiting(list, archivedSet, hiddenSet)
legendProjects = colony.plotOrder
.map((plot) => ({
name: plot.name,
accent: plot.accent,
count: list.filter((t) => !t.archived && !archivedSet.has(t.id) && t.project === plot.name).length,
urgent: colony.urgentPlots?.has(plot.id) ?? false,
}))
.sort((a, b) => b.count - a.count)
// Keep the card honest if the thread it is showing changed underneath it.
if (selectedId) {
const still = colony.agentFor(selectedId)
if (still) hud.setSelection(still, list.find((t) => t.id === selectedId) || still.thread)
else select(null, {})
}
// Which also repaints the legend, so the open zone's chip is lit by the same pass.
syncProject()
// Zones only move when their own footprint changes, and when one does the colony file
// learns about it — so the map you built up a memory of survives a reload.
const layout = colony.layoutForSave()
const signature = JSON.stringify(layout)
if (signature !== lastLayout) {
lastLayout = signature
state.plots = layout
queueSave()
}
}
/**
* The one sound that is allowed to interrupt: a thread that has just put its hand up. Once
* per thread per wait, never on the first roster (a reload is not news), and never more
* than one chime a couple of seconds apart however many arrive at once.
*/
const waitingBefore = new Set()
let seenFirstRoster = false
let lastChime = 0
function chimeForNewWaiting(list, archivedSet, hiddenSet) {
const now = Date.now()
const waiting = new Set()
for (const t of list) {
if (archivedSet.has(t.id) || hiddenSet.has(t.project)) continue
if (statusFor(t, now) === 'waiting') waiting.add(t.id)
}
if (seenFirstRoster) {
for (const id of waiting) {
if (waitingBefore.has(id) || now - lastChime < 2500) continue
lastChime = now
const agent = colony.agentFor(id)
ambience.play('chime-attention', agent ? { x: agent.pos.x, y: agent.pos.y + 1, z: agent.pos.z, gain: 0.9 } : { gain: 0.9 })
}
}
seenFirstRoster = true
waitingBefore.clear()
for (const id of waiting) waitingBefore.add(id)
}
let polling = false
async function poll() {
if (polling) return
polling = true
try {
const res = await fetchThreads()
applyThreads(res.threads || [])
hud.removeBoot()
} catch (err) {
hud.toast(err.message || 'Could not reach the thread scanner', 'err')
hud.removeBoot()
} finally {
polling = false
}
}
function queueSave() {
clearTimeout(pendingSave)
pendingSave = setTimeout(async () => {
try {
// Adopt whatever comes back: unchanged when the save was clean, and the merged colony when
// another tab had written since this one loaded. Dropping it would leave this page
// asserting a picture the file has already moved past, and the next save would fight.
state = await saveState(state)
} catch {
/* the colony still runs; only the archive list is at risk, and it retries next time */
}
}, 500)
}
async function boot() {
// The model kit and the crew rig both have to be in hand before the first roster arrives:
// buildings and the ground scatter are assembled out of the kit synchronously the moment
// a thread shows up, and the crew's body mesh is built from the rig. Fetched alongside
// the saved state rather than after it, since none of them waits on the others.
const settle = (p) => p.then(() => null, (err) => err)
const [, kitError, crewError] = await Promise.all([
fetchState()
.then((s) => {
state = s
// Before the first roster: zones come back to the ground they were on last time.
colony.restoreLayout(state.plots)
// And the settings, but only for a browser that has none of its own — an explicit
// choice made here always outranks the file.
if (!hasStoredSettings() && state.settings) settings.applyAll(state.settings)
})
.catch(() => {
// Not "first run, or the file is gone": the server answers a missing file with an empty
// state rather than an error, so a rejection means it could not be reached and we do not
// know what is on disk. Saves stay off for this session and say so, because an archive
// that silently fails to persist is worse than one that refuses.
hud.toast('Could not read the saved colony — archiving is off until you reload', 'err')
}),
settle(loadKit()),
settle(loadCrew()),
])
if (kitError || crewError) {
hud.toast('Could not load the model assets — run `npm run assets`', 'err')
console.error(kitError || crewError)
}
colony.astronauts.setRig(crewRig())
if (!kitError) colony.onAssetsReady()
await poll()
setInterval(poll, POLL_MS)
window.addEventListener('focus', poll)
// A tab that was hidden for an hour should catch up the moment it comes back.
document.addEventListener('visibilitychange', () => {
if (!document.hidden) poll()
})
if (!localStorage.getItem('botcrossing.seen-help')) {
hud.toggleHelp(true)
localStorage.setItem('botcrossing.seen-help', '1')
} else {
hud.hint('Drag to move · click a bot · H hides everything', 5200)
}
}
// ── settings plumbing ─────────────────────────────────────────────────────────────────
settings.onChange((changed, scope) => {
// Kept in the colony file as well as in this browser's own storage. `localStorage` is
// per *origin*, so a dev server that comes back on a different port looks to the browser
// like a different site and hands you factory settings — the file does not care.
state.settings = { ...settings.values }
queueSave()
if (scope.render || changed.has('fov')) engine.applySettings()
if (changed.has('planet')) engine.setPlanetGrade(PLANETS[settings.get('planet')]?.grade)
colony.onSettingsChanged(changed, scope)
if (changed.has('planet')) ambience.setPlanet(colony.planet)
if (changed.has('showFps')) hud.syncSettings()
if (changed.has('followSelected')) rig.setFollow(settings.get('followSelected') ? colony.agentFor(selectedId) : null)
// Folding dormant repos away changes which threads are on the map, so the colony has to be
// rebuilt from the list rather than merely re-rendered.
if (changed.has('hideDormant')) applyThreads(threads)
if (changed.has('maxAgents')) applyThreads(threads)
})
// ── frame ─────────────────────────────────────────────────────────────────────────────
engine.add({
update(dt, elapsed) {
rig.setFollow(settings.get('followSelected') ? colony.agentFor(selectedId) : null)
rig.update(dt)
// The world bends away from wherever the camera is looking, every frame, before the
// colony projects anything to the screen.
setCurveView(rig.target, rig.azimuth, settings.get('worldCurve') * CURVE_FULL)
colony.update(dt, elapsed, rig.target)
// Whatever the camera is orbiting is what should be in focus.
engine.setFocusDistance(rig.distance)
if (selectedId) {
hud.updateAvatar(colony.astronauts.faceTexture.image)
// A selected astronaut that walked off the roster should not keep a stale card open.
const agent = colony.agentFor(selectedId)
if (!agent) select(null, {})
else hud.placeCard(screenOf(agent))
}
hud.setFps(engine.perf, engine.viewport, `${colony.astronauts.visibleCount} bots · ${colony.particles.liveCount} bits`)
ambience.update(dt, engine.camera, soundWorld())
},
})
// ── what the world sounds like ────────────────────────────────────────────────────────
const soundSources = []
const soundWater = { level: 0, points: [] }
const shipSpot = shipPosition()
/**
* Everything making a noise right now, as positional sources. Objects are reused between
* frames so the audio engine can key voices by id without anything being allocated.
*/
function soundWorld() {
let n = 0
const take = (id, sound, x, y, z, gain = 1) => {
const s = soundSources[n] || (soundSources[n] = { id: '', sound: '', x: 0, y: 0, z: 0, gain: 1 })
s.id = id
s.sound = sound
s.x = x
s.y = y
s.z = z
s.gain = gain
n++
}
for (const agent of colony.astronauts.agents) {
if (agent.state === 'at-site' && agent.status === 'working' && agent.scale > 0.5) {
take(`work:${agent.id}`, 'work-hammer', agent.pos.x, agent.pos.y + 0.6, agent.pos.z, 0.9)
}
}
take('ship', 'ship-hum', shipSpot.x, colony.ship.group.position.y + 3, shipSpot.z, 0.7)
colony.fauna.drones.forEach((d, i) => take(`drone:${i}`, 'drone-whine', d.x, d.y, d.z, d.busy ? 1 : 0.35))
soundSources.length = n
let water = null
if (colony.planet.water && colony.planet.audio?.shore) {
// The half-dozen bits of shoreline nearest the view: the whole coast is hundreds of
// points, and the engine only has ten voices to give out.
const all = shorelinePoints(colony.planet)
const t = rig.target
soundWater.points = all
.map((p) => ({ p, d: (p.x - t.x) * (p.x - t.x) + (p.z - t.z) * (p.z - t.z) }))
.sort((a, b) => a.d - b.d)
.slice(0, 6)
.map((e) => e.p)
soundWater.level = colony.planet.water.level
// Where the view is, so the surf beds can be quieter the further inland it sits.
soundWater.focusX = t.x
soundWater.focusZ = t.z
water = soundWater
}
return { night: colony.sky.nightFactor ?? 0, sources: soundSources, water }
}
engine.start()
boot()
// Handy for poking at the running colony from the console.
window.botCrossing = { engine, rig, colony, settings, hud, ambience, poll, get threads() { return threads } }
/** `execCommand('copy')` over a throwaway textarea — the copy that predates permissions. */
function copyFallback(text) {
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.cssText = 'position:fixed;top:0;opacity:0;pointer-events:none'
document.body.appendChild(el)
el.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
el.remove()
return ok
}
function stamp() {
const d = new Date()
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
}