openvibers/openvibe.games Commits
Source-style triggers and entity I/O: trigger_multiple/once/push/teleport, relays, timers, outputs (#198)
Changes
@@@ -21,7 +21,7 @@ export const newId = (prefix: string): string =>export interface Placeable {name: string-- kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc'++ kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc' | 'trigger'shape?: StaticBody['shape']color?: stringtex?: string@@@ -69,6 +69,7 @@ export const ENTITY_DEFS: Placeable[] = [{ name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },{ name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },{ name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },++ { name: '🔘 Trigger volume', kind: 'trigger' },]export const TEXTURES = [@@@ -58,7 +58,7 @@ import { ToolManager } from './tools/toolManager.js'import { PlacementTool, objectForPlacement } from './tools/placementTool.js'import { sculptDab, toTerrainLocal, type SculptMode } from './tools/terrainTool.js'import { lightForPlacement, type LightType } from './tools/lightTool.js'--import { zoneForPlacement } from './tools/zoneTool.js'++import { triggerForPlacement, zoneForPlacement } from './tools/zoneTool.js'import { EditorCameraController } from './viewport/editorCameraController.js'import { EditorViewRegistry } from './viewport/editorViewRegistry.js'import { GizmoController, type GizmoMode } from './viewport/gizmoController.js'@@@ -662,16 +662,20 @@ export async function bootEditor(): Promise<void> {const placeZone = (): void => {const { point } = creationPoint()-- const zone = zoneForPlacement({-- point,-- size: Number((document.getElementById('zone-size') as HTMLInputElement).value),-- height: Number((document.getElementById('zone-height') as HTMLInputElement).value),-- taken: (id) => doc.has(id),-- })-- history.apply(addObject('zone', zone as never, 'create zone'))++ const isTrigger = (document.getElementById('zone-trigger') as HTMLInputElement | null)?.checked++ const size = Number((document.getElementById('zone-size') as HTMLInputElement).value)++ const height = Number((document.getElementById('zone-height') as HTMLInputElement).value)++ const zone = isTrigger++ ? triggerForPlacement({ point, size, height, taken: (id) => doc.has(id) })++ : zoneForPlacement({ point, size, height, taken: (id) => doc.has(id) })++ history.apply(addObject('zone', zone as never, isTrigger ? 'create trigger' : 'create zone'))selection.replace(zone.id)ui.refreshAll()-- ui.setMessage(`🟦 ${zone.name} created — scale it with the gizmo, set rules in the Inspector`)++ ui.setMessage(++ isTrigger++ ? `🔘 ${zone.name} created — set outputs in the Inspector`++ : `🟦 ${zone.name} created — scale it with the gizmo, set rules in the Inspector`,++ )}// ── Face tool ───────────────────────────────────────────────────────@@@ -1,7 +1,7 @@import { describe, expect, it } from 'vitest'import { MapZoneSchemaV2, MapLightSchemaV2 } from '@openvibe/content'import { lightForPlacement, type LightType } from './lightTool.js'--import { nextZoneId, zoneForPlacement } from './zoneTool.js'++import { nextZoneId, triggerForPlacement, zoneForPlacement } from './zoneTool.js'const place = (type: LightType) =>lightForPlacement({@@@ -96,3 +96,12 @@ describe('zoneForPlacement', () => {expect(nextZoneId((id) => taken.has(id))).toBe('zone-3')})})++++describe('triggerForPlacement', () => {++ it('produces a trigger_multiple zone the schema accepts', () => {++ const z = triggerForPlacement({ point: [0, 1, 0], size: 4, height: 3, taken: () => false })++ const parsed = MapZoneSchemaV2.safeParse(z)++ expect(parsed.success, parsed.success ? '' : parsed.error.message).toBe(true)++ if (parsed.success) expect(parsed.data.trigger).toMatchObject({ wait: 0.2, outputs: [] })++ })++})@@@ -54,6 +54,7 @@ export function placeableShape(const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }}++ if (def.kind === 'trigger') return { type: 'box', size: [2, 2, 2] }return { type: 'box', size: [0.3, 3, 0.3] } // spawn flag pole}@@@ -234,6 +235,18 @@ export function objectForPlacement(kind: 'spawn',object: { id: 'spawn', pos: [pose.pos.x, pose.pos.y, pose.pos.z], yaw: e.y },}++ case 'trigger':++ return {++ kind: 'zone',++ object: {++ id: newId('trigger'),++ name: 'Trigger',++ min: [pose.pos.x - 1, pose.pos.y, pose.pos.z - 1],++ max: [pose.pos.x + 1, pose.pos.y + 2, pose.pos.z + 1],++ rules: { pvp: true, build: true, physgun: true },++ trigger: { wait: 0.2, outputs: [] },++ },++ }case 'patch': {const sub = 32return {@@@ -51,3 +51,27 @@ export function zoneForPlacement(p: ZonePlacement): MapZoneV2 {rules: { pvp: true, build: true, physgun: true },}}++++/** Next readable trigger id (`trigger-1`, `trigger-2`, …). */++export function nextTriggerId(taken: (id: string) => boolean): string {++ for (let n = 1; n < 10_000; n++) {++ const id = `trigger-${n}`++ if (!taken(id)) return id++ }++ return `trigger-${Date.now().toString(36)}`++}++++/** A `trigger_multiple` zone, ready for the Inspector's outputs field. */++export function triggerForPlacement(p: ZonePlacement): MapZoneV2 {++ const id = nextTriggerId(p.taken)++ const half = Math.max(0.5, p.size / 2)++ const height = Math.max(0.5, p.height)++ return {++ id,++ name: `Trigger ${id.replace('trigger-', '')}`,++ min: [p.point[0] - half, p.point[1], p.point[2] - half],++ max: [p.point[0] + half, p.point[1] + height, p.point[2] + half],++ rules: { pvp: true, build: true, physgun: true },++ trigger: { wait: 0.2, outputs: [] },++ }++}@@@ -243,6 +243,7 @@ export function buildShell(zone.append(labelled('Size', slider('zone-size', 2, 80, 1, 16)),labelled('Height', slider('zone-height', 2, 60, 1, 12)),++ labelled('Trigger', checkbox('zone-trigger')),)const face = el('div', 'strip', 'face-strip')@@@ -59,6 +59,20 @@ describe('fieldsFor', () => {)})++ it('gives a zone the trigger, push and teleport authoring fields', () => {++ const f = keys(fieldsFor('zone', null))++ expect(f).toEqual(++ expect.arrayContaining([++ 'trigger.once',++ 'trigger.wait',++ 'trigger.outputs',++ 'push.dir',++ 'push.speed',++ 'teleport.to',++ ]),++ )++ })++it('omits rotation for a resource node, which has no facing', () => {expect(keys(fieldsFor('node', null))).not.toContain('rot')})@@@ -224,6 +238,27 @@ describe('Inspector DOM', () => {expect(doc.get('s1', 'static')!.pos[0]).toBe(1)})++ it('parses the JSON outputs field before committing', () => {++ const doc = new EditorDocument()++ doc.add('zone', {++ id: 'z1',++ name: 'Z',++ min: [0, 0, 0],++ max: [1, 1, 1],++ rules: { pvp: true, build: true, physgun: true },++ trigger: { wait: 0.2, outputs: [] },++ } as never)++ new Inspector(root, doc, callbacks).setState({ ids: ['z1'], lockedBy: null })++ const el = root.querySelector('[data-field="trigger.outputs"]') as HTMLInputElement++ el.value = '[{"event":"OnStartTouch","target":"r1","input":"Trigger"}]'++ el.dispatchEvent(new Event('change'))++ expect(callbacks.setProperty).toHaveBeenCalledWith(++ ['z1'],++ 'trigger.outputs',++ [{ event: 'OnStartTouch', target: 'r1', input: 'Trigger' }],++ )++ })++it('ignores unparseable numeric input and restores the true value', () => {withStatic()const x = root.querySelector('[data-field="pos"][data-component="0"]') as HTMLInputElement@@@ -18,7 +18,14 @@ import type { EditorDocument, EditorObject, EditorObjectKind } from '../documentimport { KIND_INFO } from '../document/editorObject.js'import { commonValue, formatNumber } from './numberField.js'--export type FieldType = 'number' | 'text' | 'color' | 'checkbox' | 'select' | 'readonly'++export type FieldType =++ | 'number'++ | 'text'++ | 'color'++ | 'checkbox'++ | 'select'++ | 'readonly'++ | 'json'export interface FieldSpec {key: string@@@ -230,6 +237,14 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):{ key: 'rules.pvp', label: 'PVP', type: 'checkbox', section: 'Rules' },{ key: 'rules.build', label: 'Building', type: 'checkbox', section: 'Rules' },{ key: 'rules.physgun', label: 'Physgun', type: 'checkbox', section: 'Rules' },++ { key: 'trigger.once', label: 'Fire once', type: 'checkbox', section: 'Trigger' },++ { key: 'trigger.wait', label: 'Wait (s)', type: 'number', step: 0.05, min: 0, section: 'Trigger' },++ { key: 'trigger.outputs', label: 'Outputs (JSON)', type: 'json', section: 'Trigger' },++ { key: 'push.dir', label: 'Push dir', type: 'number', vector: 3, section: 'Push' },++ { key: 'push.speed', label: 'Push speed (u/s)', type: 'number', step: 1, min: 0, section: 'Push' },++ { key: 'push.onlyFalling', label: 'Only falling', type: 'checkbox', section: 'Push' },++ { key: 'teleport.to', label: 'Teleport target', type: 'text', section: 'Teleport' },++ { key: 'teleport.keepAngles', label: 'Keep angles', type: 'checkbox', section: 'Teleport' },]}}@@@ -278,6 +293,10 @@ export function displayValue(const bools = raw.map((v) => v === true)return bools.every((b) => b === bools[0]) ? bools[0]! : null}++ if (field.type === 'json') {++ const strings = raw.map((v) => (v === undefined || v === null ? '' : JSON.stringify(v)))++ return strings.every((s) => s === strings[0]) ? strings[0]! : null++ }const strings = raw.map((v) => (v === undefined || v === null ? '' : String(v)))return strings.every((s) => s === strings[0]) ? strings[0]! : null}@@@ -449,6 +468,16 @@ export class Inspector {this.refreshValues()return}++ if (field.type === 'json') {++ try {++ const value = JSON.parse(input.value)++ if (!Array.isArray(value)) throw new Error('not an array')++ this.commit(field, value)++ } catch {++ this.refreshValues()++ }++ return++ }if (field.type !== 'number') {this.commit(field, input.value)return@@@ -27,6 +27,7 @@ import {SettingsSystem,SocialSystem,SurfSystem,++ TriggersSystem,VehicleSystem,WorldEventSystem,buildHandlerTable,@@@ -90,6 +91,9 @@ export class GameServer {// Runs see this tick's authoritative positions: construct after movement,// insert in the ordered list before replication.const surf = new SurfSystem(ctx)++ // Triggers read the same authoritative segments and apply push/teleport++ // before the next movement step.++ const triggers = new TriggersSystem(ctx)// The wire surface every system sends through.const replication = new ReplicationSystem(ctx)ctx.net = replication@@@ -115,6 +119,7 @@ export class GameServer {chat,movement,surf,++ triggers,replication,manipulation,combat,@@@ -141,6 +146,7 @@ export class GameServer {chat,movement,surf,++ triggers,replication,manipulation,combat,@@@ -301,6 +307,9 @@ export class GameServer {// 1b. Surf run state: timer, checkpoints, fails, finish (after movement,// before replication so surf_state travels in this tick's snapshot).s.surf.tick(ctx, dt)++ // 1c. Trigger/I/O: derive touch events from the same segments, then apply++ // push/teleport so they take effect before the next movement step.++ s.triggers.tick(ctx, dt)if (this.rush) {const now = Date.now()const results = this.rush.tick(@@@ -107,6 +107,10 @@ export interface PlayerSession {surfPbSplits: number[]/** This tick's authoritative movement segments (cleared by MovementSystem). */moveSegments: { from: Vec3; to: Vec3; yaw: number }[]++ /** Per-trigger inside state, so OnStartTouch/OnEndTouch fire once per crossing. */++ triggerTouches: Map<string, boolean>++ /** Last tick a trigger fired OnStartTouch, for `trigger_multiple`'s `wait`. */++ triggerStartTicks: Map<string, number>/** Tick of the last accepted use/swing (server-side swing cooldown). */lastUseTick: number/** Stance the kinematic physics body was last built for. */@@@ -196,6 +200,8 @@ export function createSession(init: SessionInit): PlayerSession {surfWr: null,surfPbSplits: [],moveSegments: [],++ triggerTouches: new Map(),++ triggerStartTicks: new Map(),lastUseTick: 0,bodyStance: 0,content: init.content,@@@ -30,6 +30,7 @@ import type { ReplicationSystem } from './replication.js'import type { SessionsSystem } from './sessions.js'import type { SettingsSystem } from './settings.js'import type { SurfSystem } from './surf.js'++import type { TriggersSystem } from './triggers.js'import type { SurfRecords } from '../surfRecords.js'import type { SocialSystem } from './social.js'import type { VehicleSystem } from './vehicles.js'@@@ -197,6 +198,7 @@ export interface ServerSystems {chat: ChatSystemmovement: MovementSystemsurf: SurfSystem++ triggers: TriggersSystemreplication: ReplicationSystemmanipulation: ManipulationSystemcombat: CombatSystem@@@ -20,6 +20,7 @@ export { SettingsSystem, canChangeServerCvars, SERVER_CVAR_ALLOWLIST } from './sexport { ChatSystem } from '../chatCommands.js'export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'export { SurfSystem } from './surf.js'++export { TriggersSystem } from './triggers.js'export { ReplicationSystem } from './replication.js'export { ManipulationSystem } from './manipulation.js'export { CombatSystem } from './combat.js'@@@ -158,12 +158,14 @@ export class MovementSystem implements System {params: MovementParams,surfPlace: boolean,): void {++ // Always clear, even while driving: TriggersSystem reads these segments++ // for every room, and a stale previous-tick segment must never re-fire.++ session.moveSegments.length = 0if (session.driving) {ctx.systems.vehicles.driveVehicle(session)return}this.sweepSelf = ctx.sessions.bodyOf(session.playerId)-- session.moveSegments.length = 0let simulated = 0for (const input of takeInputs(session, ctx.config.tickRate)) {session.lastInput = input@@@ -171,16 +173,13 @@ export class MovementSystem implements System {session.lastProcessedSeq = input.seqapplyLookInput(session, input)session.buttons = input.intents-- const from = surfPlace-- ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)-- : null++ const from = vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)stepMovement(session.move, toMoveInput(session, input), params, this.moveQueries, dt)-- if (from)-- session.moveSegments.push({-- from,-- to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),-- yaw: session.yaw,-- })++ session.moveSegments.push({++ from,++ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),++ yaw: session.yaw,++ })simulated++}if (simulated === 0 && session.lastInput) {@@@ -191,9 +190,7 @@ export class MovementSystem implements System {if (session.starvedTicks > ctx.config.tickRate / 2) {// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys// (prone/crouch), and the server flapped stances.-- const from = surfPlace-- ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)-- : null++ const from = vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)stepMovement(session.move,toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),@@@ -201,12 +198,11 @@ export class MovementSystem implements System {this.moveQueries,dt,)-- if (from)-- session.moveSegments.push({-- from,-- to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),-- yaw: session.yaw,-- })++ session.moveSegments.push({++ from,++ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),++ yaw: session.yaw,++ })}}let bodyId = ctx.sessions.bodyOf(session.playerId)nnew file mode 100644@@@ -0,0 +1,140 @@++/* eslint-disable @typescript-eslint/no-explicit-any */++import { setMapOverride, type MapOverride, type ZoneDef } from '@openvibe/content'++import { createMoveState } from '@openvibe/gameplay'++import { vec3 } from '@openvibe/shared'++import { describe, expect, it } from 'vitest'++import { SOURCE_UNIT_TO_METRES, TriggersSystem } from './triggers.js'++++const world = { id: 'test-triggers', movement: 'default', zones: [] }++++function zone(id: string, over: Record<string, unknown> = {}): ZoneDef {++ return {++ id,++ name: id,++ min: [0, 0, 0],++ max: [2, 2, 2],++ rules: { pvp: true, build: true, physgun: true },++ ...over,++ } as unknown as ZoneDef++}++++function override(): MapOverride {++ return {++ terrains: [],++ zones: [++ zone('start', {++ min: [0, 0, 0],++ max: [2, 2, 2],++ trigger: {++ wait: 0.2,++ outputs: [++ { event: 'OnStartTouch', target: 'flag', input: 'Disable' },++ { event: 'OnEndTouch', target: 'flag', input: 'Enable' },++ ],++ },++ }),++ zone('flag', { min: [10, 0, 0], max: [12, 2, 2], trigger: { outputs: [] } }),++ zone('tp', {++ min: [20, 0, 0],++ max: [22, 2, 2],++ teleport: { to: 'dest', keepAngles: false },++ }),++ zone('boost', {++ min: [30, 0, 0],++ max: [32, 2, 2],++ push: { dir: [0, 1, 0], speed: 800, onlyFalling: false },++ }),++ ],++ points: [{ id: 'dest', pos: [5, 6, 7], yaw: 1.2, class: 'teleport_destination' }],++ logic: [],++ }++}++++function sessionFor() {++ const session = {++ playerId: 'p1',++ entityId: 'e1',++ name: 'Tester',++ yaw: 0,++ pitch: 0,++ move: createMoveState(vec3(0, 1, 0)),++ moveSegments: [] as { from: { x: number; y: number; z: number }; to: { x: number; y: number; z: number }; yaw: number }[],++ triggerTouches: new Map<string, boolean>(),++ triggerStartTicks: new Map<string, number>(),++ }++ return session++}++++function ctxFor(session: any, tick: number) {++ return {++ clock: { tick },++ config: { tickRate: 100, placeId: 'test-triggers' },++ world: {++ content: { world },++ bodyOf: () => undefined,++ physics: { setTransform: () => {} },++ entities: { get: () => undefined },++ },++ sessions: { values: () => [session][Symbol.iterator]() },++ }++}++++describe('TriggersSystem', () => {++ it('fires OnStartTouch and OnEndTouch exactly once across a crossing', () => {++ setMapOverride(override(), world.id)++ try {++ const session = sessionFor()++ const system = new TriggersSystem(ctxFor(session, 0) as any)++ const flag = (system as any).graph.triggers.find((t: any) => t.id === 'flag')++++ session.move.pos = vec3(0, 1, -2)++ session.moveSegments.push({ from: vec3(0, 1, -2), to: vec3(0, 1, 0), yaw: 0 })++ system.tick(ctxFor(session, 0) as any, 0.01)++ expect(flag.enabled).toBe(false)++++ session.moveSegments.length = 0++ session.move.pos = vec3(0, 1, 3)++ session.moveSegments.push({ from: vec3(0, 1, 0), to: vec3(0, 1, 3), yaw: 0 })++ system.tick(ctxFor(session, 1) as any, 0.01)++ expect(flag.enabled).toBe(true)++ } finally {++ setMapOverride(null, world.id)++ }++ })++++ it('teleports to a destination, zeroing velocity and setting yaw', () => {++ setMapOverride(override(), world.id)++ try {++ const session = sessionFor()++ const system = new TriggersSystem(ctxFor(session, 0) as any)++ session.move.pos = vec3(19, 1, 1)++ session.move.vel.x = 12++ session.move.vel.y = -3++ session.moveSegments.push({ from: vec3(19, 1, 1), to: vec3(21, 1, 1), yaw: 0.4 })++ system.tick(ctxFor(session, 0) as any, 0.01)++ expect(session.move.pos.x).toBeCloseTo(5)++ expect(session.move.pos.y).toBeCloseTo(6)++ expect(session.move.pos.z).toBeCloseTo(7)++ expect(session.move.vel).toEqual(vec3(0, 0, 0))++ expect(session.yaw).toBeCloseTo(1.2)++ } finally {++ setMapOverride(null, world.id)++ }++ })++++ it('applies a push velocity of speed × 0.0254 m/s', () => {++ setMapOverride(override(), world.id)++ try {++ const session = sessionFor()++ const system = new TriggersSystem(ctxFor(session, 0) as any)++ session.move.pos = vec3(29, 1, 1)++ session.moveSegments.push({ from: vec3(29, 1, 1), to: vec3(31, 1, 1), yaw: 0 })++ system.tick(ctxFor(session, 0) as any, 0.01)++ expect(session.move.vel.y).toBeCloseTo(800 * SOURCE_UNIT_TO_METRES)++ expect(session.move.vel.x).toBe(0)++ expect(session.move.vel.z).toBe(0)++ } finally {++ setMapOverride(null, world.id)++ }++ })++})nnew file mode 100644@@@ -0,0 +1,109 @@++import { getMapOverride } from '@openvibe/content'++import {++ placeMoveStateAt,++ touchTransition,++ TriggerGraph,++ type TriggerZoneRuntime,++} from '@openvibe/gameplay'++import { qfromYaw, vec3 } from '@openvibe/shared'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * Source unit → metre conversion for authored movement values. 1 gameplay++ * unit = 0.0254 m (see the movement research; do not use the 16 u/ft++ * architecture scale here).++ */++export const SOURCE_UNIT_TO_METRES = 0.0254++++/**++ * Deterministic trigger/I/O execution. It runs AFTER MovementSystem (so it++ * sees this tick's authoritative segments) and AFTER SurfSystem (so surf's++ * own fail/checkpoint teleports stay authoritative for a surf place). Push++ * and teleport are applied here, so they land before the next movement step.++ */++export class TriggersSystem implements System {++ readonly name = 'triggers'++ private readonly graph: TriggerGraph++++ constructor(private readonly ctx: ServerContext) {++ const override = getMapOverride(ctx.world.content.world)++ this.graph = new TriggerGraph({++ zones: override?.zones ?? [],++ points: override?.points ?? [],++ logic: override?.logic ?? [],++ tickRate: ctx.config.tickRate,++ })++ }++++ tick(ctx: ServerContext, _dt: number): void {++ const now = ctx.clock.tick++ this.graph.tick(now)++ for (const session of ctx.sessions.values()) this.stepSession(ctx, session, now)++ // Delay-0 edges scheduled by a touch event fire in this same tick.++ this.graph.drain(now)++ }++++ private stepSession(ctx: ServerContext, session: PlayerSession, now: number): void {++ for (const segment of session.moveSegments) {++ for (const trigger of this.graph.triggers) {++ if (!trigger.enabled) continue++ const wasInside = session.triggerTouches.get(trigger.id) ?? false++ const transition = touchTransition(trigger, segment.from, segment.to, wasInside)++ session.triggerTouches.set(trigger.id, transition.nowInside)++ if (transition.kind === 'start') {++ this.fireStart(session, trigger, now)++ this.applyTeleport(ctx, session, trigger)++ } else if (transition.kind === 'end') {++ this.graph.fireTouchOutputs(trigger, 'OnEndTouch', now)++ }++ if (transition.nowInside) this.applyPush(session, trigger)++ }++ }++ }++++ private fireStart(session: PlayerSession, trigger: TriggerZoneRuntime, now: number): void {++ if (!trigger.trigger) return++ const waitTicks = Math.round(trigger.trigger.wait * this.ctx.config.tickRate)++ const last = session.triggerStartTicks.get(trigger.id) ?? Number.NEGATIVE_INFINITY++ if (waitTicks > 0 && now - last < waitTicks) return++ session.triggerStartTicks.set(trigger.id, now)++ this.graph.fireTouchOutputs(trigger, 'OnStartTouch', now)++ if (trigger.trigger.once) trigger.enabled = false++ }++++ private applyTeleport(ctx: ServerContext, session: PlayerSession, trigger: TriggerZoneRuntime): void {++ if (trigger.kind !== 'trigger_teleport' || !trigger.teleport) return++ const dest = this.graph.points.get(trigger.teleport.to)++ if (!dest) return++ const yaw = trigger.teleport.keepAngles ? session.yaw : dest.yaw++ this.teleport(ctx, session, dest.pos, yaw)++ }++++ private teleport(++ ctx: ServerContext,++ session: PlayerSession,++ pos: { x: number; y: number; z: number },++ yaw: number,++ ): void {++ placeMoveStateAt(session.move, pos)++ session.yaw = yaw++ const body = ctx.world.bodyOf(session.entityId)++ if (body !== undefined)++ ctx.world.physics.setTransform(body, vec3(pos.x, pos.y + 0.15, pos.z))++ const entity = ctx.world.entities.get(session.entityId)++ if (entity) qfromYaw(entity.transform.rot, yaw)++ }++++ private applyPush(session: PlayerSession, trigger: TriggerZoneRuntime): void {++ if (trigger.kind !== 'trigger_push' || !trigger.push) return++ if (trigger.push.onlyFalling && session.move.grounded) return++ const [dx, dy, dz] = trigger.push.dir++ const len = Math.hypot(dx, dy, dz) || 1++ const m = trigger.push.speed * SOURCE_UNIT_TO_METRES++ session.move.vel.x = (dx / len) * m++ session.move.vel.y = (dy / len) * m++ session.move.vel.z = (dz / len) * m++ }++}@@@ -1060,6 +1060,8 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {],models: [],textures: [],++ points: [],++ logic: [],spawn: [ISLAND.spawn[0], round(top(ISLAND.spawn[0], ISLAND.spawn[2])), ISLAND.spawn[2]],spawnYaw: 0,}@@@ -449,6 +449,86 @@ describe('map-authored zones', () => {})})++describe('trigger/I/O round trip', () => {++ const mapWithIo = (): MapFileV2 => ({++ ...emptyMapV2(),++ zones: [++ {++ id: 'start',++ name: 'Start',++ min: [-2, 0, -2],++ max: [2, 3, 2],++ rules: { pvp: true, build: true, physgun: true },++ trigger: {++ wait: 0.2,++ outputs: [{ event: 'OnStartTouch', target: 'relay', input: 'Trigger' }],++ },++ },++ {++ id: 'boost',++ name: 'Boost',++ min: [10, 0, 10],++ max: [12, 3, 12],++ rules: { pvp: true, build: true, physgun: true },++ push: { dir: [0, 1, 0], speed: 800 },++ },++ {++ id: 'tp',++ name: 'Teleport',++ min: [20, 0, 20],++ max: [22, 3, 22],++ rules: { pvp: true, build: true, physgun: true },++ teleport: { to: 'dest', keepAngles: false },++ },++ ],++ points: [{ id: 'dest', pos: [0, 1, 0], yaw: 1.5, class: 'teleport_destination' }],++ logic: [++ { id: 'relay', class: 'relay', outputs: [{ event: 'OnTrigger', target: 'boost', input: 'Enable' }] },++ { id: 'auto1', class: 'auto', outputs: [{ event: 'OnMapSpawn', target: 'relay', input: 'Trigger' }] },++ ],++ })++++ it('parses, validates and compiles points/logic/trigger/push/teleport', () => {++ const r = parseMapFile(mapWithIo())++ expect(r.ok, r.ok ? '' : r.issues.join(', ')).toBe(true)++ if (!r.ok) return++ expect(validateMapFile(r.map)).toEqual([])++ const override = compileMapFileV2(r.map)++ expect(override.points).toEqual([++ { id: 'dest', pos: [0, 1, 0], yaw: 1.5, class: 'teleport_destination' },++ ])++ expect(override.logic).toHaveLength(2)++ expect(override.zones?.[0]?.trigger?.outputs).toHaveLength(1)++ expect(override.zones?.[1]?.push?.speed).toBe(800)++ })++++ it('survives a save/reload round trip', () => {++ const r = parseMapFile(mapWithIo())++ expect(r.ok).toBe(true)++ if (!r.ok) return++ const again = parseMapFile(JSON.parse(canonicalizeMapFile(r.map)))++ expect(again.ok).toBe(true)++ if (again.ok) expect(again.map).toEqual(r.map)++ })++++ it('rejects a missing output target and a missing teleport destination', () => {++ const m = mapWithIo()++ m.zones[0]!.trigger!.outputs[0]!.target = 'ghost'++ const missingTarget = validateMapFile(m).join(' ')++ expect(missingTarget).toContain('missing entity "ghost"')++++ const m2 = mapWithIo()++ m2.zones[2]!.teleport!.to = 'nope'++ expect(validateMapFile(m2).join(' ')).toContain('is not a point')++ })++++ it('rejects an unknown input through the zod enum', () => {++ const m = mapWithIo()++ m.zones[0]!.trigger!.outputs[0]!.input = 'DoSomething' as never++ expect(parseMapFile(m).ok).toBe(false)++ })++})++describe('map-authored NPCs', () => {it('an older map with no npcs reads as none, and a new one starts with none', () => {const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>@@@ -15,13 +15,17 @@ import { z } from 'zod'import {FaceStyleSchema,HexColorSchema,++ LogicDefSchema,MapLightSchema,++ PointDefSchema,StaticBodySchema,ZoneDefSchema,MovementStyleSchema,SurfMapMetaSchema,type FaceStyle,++ type LogicDef,type MovementStyle,++ type PointDef,type SurfMapMeta,type ZoneDef,} from './schema/world.js'@@@ -171,6 +175,10 @@ export const MapFileV2Schema = z.object({lights: z.array(MapLightSchemaV2).max(256).default([]),/** Map-authored zones; they augment the base world's content zones. */zones: z.array(MapZoneSchemaV2).max(512).default([]),++ /** Named point entities (`info_target`, `info_teleport_destination`, cameras). */++ points: z.array(PointDefSchema).max(4096).default([]),++ /** Logic entities: relay, auto and timer. */++ logic: z.array(LogicDefSchema).max(4096).default([]),models: z.array(z.object({ id: z.string(), name: z.string(), glb: z.string(), bounds: vec3 })).max(512)@@@ -199,6 +207,8 @@ export type TerrainObjectV2 = z.infer<typeof TerrainObjectSchemaV2>export type StaticObjectV2 = z.infer<typeof StaticObjectSchemaV2>export type MapLightV2 = z.infer<typeof MapLightSchemaV2>export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>++export type MapPointV2 = PointDef++export type MapLogicV2 = LogicDefexport type MapNodeV2 = MapFileV2['nodes'][number]export type MapPropV2 = MapFileV2['props'][number]export type MapNpcV2 = MapFileV2['npcs'][number]@@@ -220,6 +230,8 @@ export function emptyMapV2(): MapFileV2 {npcs: [],lights: [],zones: [],++ points: [],++ logic: [],models: [],textures: [],}@@@ -233,6 +245,8 @@ const ID_KINDS = [['props', 'pr'],['lights', 'light'],['zones', 'zone'],++ ['points', 'point'],++ ['logic', 'logic'],['npcs', 'npc'],] as const@@@ -365,36 +379,97 @@ export function validateMapFile(map: MapFileV2): string[] {for (const n of map.npcs) claim(n.id, 'npc')for (const l of map.lights) claim(l.id, 'light')for (const z0 of map.zones) claim(z0.id, 'zone')++ for (const p of map.points) claim(p.id, 'point')++ for (const l of map.logic) claim(l.id, 'logic')if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)++ issues.push(...ioIssues(map))if (map.surf) issues.push(...surfIssues(map))return issues}++/**++ * Cross-field I/O checks the schema cannot express: every output target must++ * resolve to an entity id, and a teleport destination must be a point.++ * Inputs are already type-checked by `IoInputSchema`; here we check the edges.++ */++function ioIssues(map: MapFileV2): string[] {++ const issues: string[] = []++ const points = new Set(map.points.map((p) => p.id))++ const targets = new Set<string>([++ ...map.points.map((p) => p.id),++ ...map.logic.map((l) => l.id),++ ...map.zones.map((z) => z.id),++ ...map.statics.map((s) => s.brushEntity?.targetname ?? s.id),++ ])++++ const checkOutputs = (owner: string, outputs: readonly { target: string }[]): void => {++ for (const o of outputs)++ if (!targets.has(o.target)) issues.push(`${owner}: output targets missing entity "${o.target}"`)++ }++++ for (const z of map.zones) {++ if (z.teleport && !points.has(z.teleport.to))++ issues.push(`zone "${z.id}": teleport destination "${z.teleport.to}" is not a point`)++ if (z.push) {++ const [x, y, zz] = z.push.dir++ if (Math.hypot(x, y, zz) < 1e-9)++ issues.push(`zone "${z.id}": push dir must not be the zero vector`)++ }++ if (z.trigger) checkOutputs(`zone "${z.id}"`, z.trigger.outputs)++ }++ for (const l of map.logic) checkOutputs(`logic "${l.id}"`, l.outputs)++ for (const s of map.statics)++ if (s.brushEntity) checkOutputs(`static "${s.id}"`, s.brushEntity.outputs)++ return issues++}++++/** `surf.kind` plus the Source timer zone-name aliases. */++function surfMetaFor(++ zone: MapZoneV2,++ stages: number,++): { kind: 'start' | 'checkpoint' | 'finish' | 'kill'; stage: number; spawn?: [number, number, number] } | null {++ if (zone.surf) {++ return {++ kind: zone.surf.kind,++ stage: zone.surf.stage,++ ...(zone.surf.spawn ? { spawn: zone.surf.spawn } : {}),++ }++ }++ const id = zone.id++ if (id === 'map_start' || zone.name === 'map_start') return { kind: 'start', stage: 0 }++ if (id === 'map_end' || zone.name === 'map_end') return { kind: 'finish', stage: stages }++ if (id === 'map_kill' || zone.name === 'map_kill') return { kind: 'kill', stage: 0 }++ const cp = /^map_cp(\d+)$/.exec(id) ?? /^map_cp(\d+)$/.exec(zone.name)++ if (cp) return { kind: 'checkpoint', stage: Number(cp[1]) }++ return null++}++/** A surf map's zones must describe exactly one ordered run. */function surfIssues(map: MapFileV2): string[] {const issues: string[] = []const surf = map.surfif (!surf) return issues-- const zones = map.zones.filter((z) => z.surf)-- const starts = zones.filter((z) => z.surf!.kind === 'start')-- const finishes = zones.filter((z) => z.surf!.kind === 'finish')++ const zones = map.zones++ .map((z) => ({ zone: z, meta: surfMetaFor(z, surf.stages) }))++ .filter((x) => x.meta !== null)++ const starts = zones.filter((z) => z.meta!.kind === 'start')++ const finishes = zones.filter((z) => z.meta!.kind === 'finish')if (starts.length !== 1) issues.push(`surf: expected one start zone, found ${starts.length}`)if (finishes.length !== 1) issues.push(`surf: expected one finish zone, found ${finishes.length}`)if (map.movement !== 'surf') issues.push('surf: a surf map must set movement to "surf"')-- for (const zone of zones) {-- const meta = zone.surf!-- const maxStage = meta.kind === 'finish' ? surf.stages : surf.stages - 1-- if (meta.stage > maxStage)-- issues.push(`surf zone "${zone.id}": stage ${meta.stage} exceeds ${maxStage}`)-- if (meta.kind === 'checkpoint' && !meta.spawn)++ for (const { zone, meta } of zones) {++ const maxStage = meta!.kind === 'finish' ? surf.stages : surf.stages - 1++ if (meta!.stage > maxStage)++ issues.push(`surf zone "${zone.id}": stage ${meta!.stage} exceeds ${maxStage}`)++ if (meta!.kind === 'checkpoint' && !meta!.spawn)issues.push(`surf zone "${zone.id}": a checkpoint needs a respawn point`)}-- const checkpoints = zones.filter((z) => z.surf!.kind === 'checkpoint')++ const checkpoints = zones.filter((z) => z.meta!.kind === 'checkpoint')if (checkpoints.length !== surf.stages - 1)issues.push(`surf: ${checkpoints.length} checkpoints for ${surf.stages} stages (expected ${surf.stages - 1})`,)-- const stages = checkpoints.map((z) => z.surf!.stage).sort((a, b) => a - b)++ const stages = checkpoints.map((z) => z.meta!.stage).sort((a, b) => a - b)for (let i = 0; i < stages.length; i++)if (stages[i] !== i + 1) {issues.push(`surf: checkpoint stages must be 1..${surf.stages - 1}, got ${stages.join(',')}`)@@@ -471,6 +546,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {})),statics: map.statics.map((b) => ({ ...b })),zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),++ points: map.points.map((p) => ({ ...p, pos: [...p.pos] })),++ logic: map.logic.map((l) => ({ ...l })),...(map.movement ? { movement: map.movement } : {}),...(map.surf ? { surf: map.surf } : {}),...(map.spawn ? { spawn: map.spawn } : {}),@@@ -91,6 +91,104 @@ export const FaceStyleSchema = z.object({})export type FaceStyle = z.infer<typeof FaceStyleSchema>++/**++ * Inputs an entity accepts through the deterministic I/O graph. Source has a++ * much larger vocabulary; this is the subset the surf trigger core needs plus++ * the door/button inputs the next tier will drive. An unknown input is a++ * parse error, so a typo in the editor cannot silently do nothing.++ */++export const IoInputSchema = z.enum([++ 'Enable',++ 'Disable',++ 'Toggle',++ 'Trigger',++ 'Open',++ 'Close',++ 'Press',++ 'Teleport',++ 'SetSpeed',++ 'ResetTimer',++ 'FireTimer',++ 'CancelPending',++ 'TouchTest',++])++export type IoInput = z.infer<typeof IoInputSchema>++++/**++ * One edge in the event graph: "when `event` happens, send `input` to++ * `target` after `delay` seconds". `once` means the edge fires at most once++ * for the lifetime of the map.++ */++export const OutputSchema = z.object({++ event: z.string().min(1).max(64),++ target: z.string().min(1).max(64),++ input: IoInputSchema,++ param: z.string().optional(),++ delay: z.number().finite().nonnegative().optional(),++ once: z.boolean().optional(),++})++export type Output = z.infer<typeof OutputSchema>++++/** `trigger_multiple` / `trigger_once`: a volume that fires touch outputs. */++export const TriggerZoneSchema = z.object({++ wait: z.number().finite().nonnegative().optional(),++ once: z.boolean().optional(),++ outputs: z.array(OutputSchema).default([]),++})++export type TriggerZone = z.infer<typeof TriggerZoneSchema>++++/** `trigger_push`: a volume that applies a velocity to players inside it. */++export const PushZoneSchema = z.object({++ dir: finiteVec3,++ /** Magnitude in Source units/s; the engine converts to m/s at runtime. */++ speed: z.number().finite().min(0),++ onlyFalling: z.boolean().optional(),++})++export type PushZone = z.infer<typeof PushZoneSchema>++++/** `trigger_teleport`: a volume that moves a player to a destination point. */++export const TeleportZoneSchema = z.object({++ to: z.string().min(1).max(64),++ landmark: z.string().min(1).max(64).optional(),++ keepAngles: z.boolean().optional(),++})++export type TeleportZone = z.infer<typeof TeleportZoneSchema>++++/**++ * A point entity (`info_target`, `info_teleport_destination`, camera marker):++ * a named position and optional facing, referenced by trigger outputs.++ */++export const PointDefSchema = z.object({++ id: z.string().regex(/^[a-z0-9_-]+$/),++ pos: finiteVec3,++ yaw: z.number().optional(),++ class: z.enum(['target', 'teleport_destination', 'camera']).default('target'),++})++export type PointDef = z.infer<typeof PointDefSchema>++++/**++ * A logic entity: `logic_relay` forwards inputs, `logic_auto` fires on map++ * spawn, and `logic_timer` fires on an interval.++ */++export const LogicDefSchema = z.object({++ id: z.string().regex(/^[a-z0-9_-]+$/),++ class: z.enum(['relay', 'auto', 'timer']),++ wait: z.number().finite().nonnegative().optional(),++ outputs: z.array(OutputSchema).default([]),++})++export type LogicDef = z.infer<typeof LogicDefSchema>++++/** A brush entity (`func_door`, `func_button`, `func_brush`) attached to a static. */++export const BrushEntitySchema = z.object({++ class: z.enum(['door', 'button', 'brush']),++ targetname: z.string().min(1).max(64).optional(),++ speed: z.number().finite().nonnegative().optional(),++ lip: z.number().finite().nonnegative().optional(),++ wait: z.number().finite().nonnegative().optional(),++ outputs: z.array(OutputSchema).default([]),++})++export type BrushEntity = z.infer<typeof BrushEntitySchema>++export const StaticBodySchema = z.object({/** Stable editor/document id (selection, collab locks). */id: z.string().optional(),@@@ -119,6 +217,8 @@ export const StaticBodySchema = z.object({uv: FaceStyleSchema.optional(),/** Per-face overrides for box statics (face index '0'..'5'). */faces: z.record(z.string(), FaceStyleSchema).optional(),++ /** Optional brush-entity behaviour (`func_door`/`func_button`/`func_brush`). */++ brushEntity: BrushEntitySchema.optional(),})export const ResourceNodeSpawnSchema = z.object({@@@ -175,6 +275,12 @@ export const ZoneDefSchema = z.object({}),/** Surf trigger metadata; absent on ordinary rule zones. */surf: SurfZoneMetaSchema.optional(),++ /** `trigger_multiple` / `trigger_once` touch outputs. */++ trigger: TriggerZoneSchema.optional(),++ /** `trigger_push` velocity volume. */++ push: PushZoneSchema.optional(),++ /** `trigger_teleport` destination volume. */++ teleport: TeleportZoneSchema.optional(),})export const WorldDefSchema = z.object({@@@ -1,5 +1,5 @@import type { SurfaceMaterialData } from './surface.js'--import type { FaceStyle, StaticBody, WorldDef, ZoneDef } from './schema/world.js'++import type { FaceStyle, LogicDef, PointDef, StaticBody, WorldDef, ZoneDef } from './schema/world.js'/*** Deterministic terrain heightfield shared by server physics, client@@@ -189,6 +189,10 @@ export interface MapOverride {* The base list is never mutated — see `ZoneIndex.setMapZones`.*/zones?: ZoneDef[]++ /** Named point entities (`info_target`, `info_teleport_destination`, cameras). */++ points?: PointDef[]++ /** Logic entities: relay, auto and timer. */++ logic?: LogicDef[]/** Editor-placed spawn point (overrides the world def). */spawn?: [number, number, number]spawnYaw?: numbernnew file mode 100644@@@ -0,0 +1,74 @@++/**++ * The deterministic I/O queue. One event edge is an output: when `event`++ * fires, send `input` to `target` after `delay` seconds. Seconds become++ * whole ticks with `Math.round`, so the same map and the same tick rate++ * always produce the same ordering — server and prediction cannot disagree++ * about when something fires.++ */++++export interface OutputSpec {++ event: string++ target: string++ input: string++ param?: string | undefined++ delay?: number | undefined++ once?: boolean | undefined++}++++export interface QueuedOutput {++ dueTick: number++ /** Monotonic insertion order, the tie-breaker when two edges share a tick. */++ seq: number++ event: string++ target: string++ input: string++ param?: string++ once?: boolean++}++++export function delayTicks(delaySeconds: number | undefined, tickRate: number): number {++ if (delaySeconds === undefined || delaySeconds <= 0) return 0++ return Math.round(delaySeconds * tickRate)++}++++/** A stable identity for an output, used to honour `once`. */++export function outputKey(output: OutputSpec): string {++ return `${output.event}\u0000${output.target}\u0000${output.input}\u0000${output.param ?? ''}`++}++++export class OutputQueue {++ private items: QueuedOutput[] = []++ private nextSeq = 0++++ get length(): number {++ return this.items.length++ }++++ schedule(outputs: readonly OutputSpec[], nowTick: number, tickRate: number): void {++ for (const output of outputs) {++ this.items.push({++ dueTick: nowTick + delayTicks(output.delay, tickRate),++ seq: this.nextSeq++,++ event: output.event,++ target: output.target,++ input: output.input,++ ...(output.param !== undefined ? { param: output.param } : {}),++ ...(output.once !== undefined ? { once: output.once } : {}),++ })++ }++ this.items.sort((a, b) => a.dueTick - b.dueTick || a.seq - b.seq)++ }++++ /** Pop every edge due at or before `nowTick`, in (dueTick, seq) order. */++ due(nowTick: number): QueuedOutput[] {++ const out: QueuedOutput[] = []++ while (this.items.length > 0 && this.items[0]!.dueTick <= nowTick) {++ out.push(this.items.shift()!)++ }++ return out++ }++++ clear(): void {++ this.items = []++ }++}nnew file mode 100644@@@ -0,0 +1,102 @@++import type { LogicDef } from '@openvibe/content'++import { delayTicks, type OutputSpec } from './io.js'++++/**++ * Runtime logic entities: relay forwards inputs, auto fires on map spawn,++ * and timer fires on an interval. All are pure data plus a small state++ * machine — no wall clock, only ticks.++ */++++export type LogicClass = 'relay' | 'auto' | 'timer'++++export interface LogicEntity {++ id: string++ class: LogicClass++ enabled: boolean++ wait: number++ outputs: OutputSpec[]++ /** Timer only: the tick at which the next OnTimer is due. */++ nextFireTick: number | null++}++++export function buildLogicEntity(def: LogicDef): LogicEntity {++ return {++ id: def.id,++ class: def.class,++ enabled: def.class !== 'auto',++ wait: def.wait ?? 0,++ outputs: def.outputs.map((o) => ({ ...o })),++ nextFireTick: null,++ }++}++++export function timerIntervalTicks(entity: LogicEntity, tickRate: number): number {++ return Math.max(1, delayTicks(entity.wait, tickRate))++}++++/**++ * Fire an input on a logic entity. Returns the outputs it wants scheduled.++ * Timer scheduling lives here so `Enable`/`ResetTimer` and the graph's tick++ * cannot disagree about the next due tick.++ */++export function fireLogicInput(++ entity: LogicEntity,++ input: string,++ nowTick: number,++ tickRate: number,++): OutputSpec[] {++ switch (entity.class) {++ case 'relay': {++ if (input === 'Trigger') {++ if (!entity.enabled) return []++ return entity.outputs++ }++ if (input === 'Enable') {++ entity.enabled = true++ return []++ }++ if (input === 'Disable') {++ entity.enabled = false++ return []++ }++ if (input === 'Toggle') {++ entity.enabled = !entity.enabled++ return []++ }++ return []++ }++ case 'auto': {++ // Auto has no runtime inputs; its OnMapSpawn is seeded by the graph.++ return []++ }++ case 'timer': {++ if (input === 'Enable') {++ entity.enabled = true++ entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)++ return []++ }++ if (input === 'Disable') {++ entity.enabled = false++ entity.nextFireTick = null++ return []++ }++ if (input === 'Toggle') {++ entity.enabled = !entity.enabled++ entity.nextFireTick = entity.enabled++ ? nowTick + timerIntervalTicks(entity, tickRate)++ : null++ return []++ }++ if (input === 'ResetTimer') {++ if (entity.enabled) entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)++ return []++ }++ if (input === 'FireTimer') {++ const outputs = entity.outputs++ if (entity.enabled) entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)++ return outputs++ }++ return []++ }++ }++}nnew file mode 100644@@@ -0,0 +1,111 @@++import { describe, expect, it } from 'vitest'++import type { ZoneDef } from '@openvibe/content'++import { delayTicks, OutputQueue } from './io.js'++import {++ buildTriggerZone,++ classifyZone,++ touchTransition,++ TriggerGraph,++} from './trigger.js'++++const zone = (over: Record<string, unknown> = {}): ZoneDef =>++ ({++ id: 't1',++ name: 'Trigger',++ min: [-1, 0, -1],++ max: [1, 2, 1],++ rules: { pvp: true, build: true, physgun: true },++ ...over,++ }) as unknown as ZoneDef++++const p = (x = 0, y = 1, z = 0): { x: number; y: number; z: number } => ({ x, y, z })++++describe('OutputQueue', () => {++ it('orders by due tick, then insertion sequence', () => {++ const q = new OutputQueue()++ q.schedule([{ event: 'a', target: 'x', input: 'Trigger', delay: 2 }], 0, 10)++ q.schedule([{ event: 'b', target: 'x', input: 'Trigger', delay: 1 }], 0, 10)++ q.schedule([{ event: 'c', target: 'x', input: 'Trigger', delay: 1 }], 0, 10)++ expect(delayTicks(2, 10)).toBe(20)++ expect(delayTicks(1, 10)).toBe(10)++ expect(q.due(9)).toEqual([])++ expect(q.due(10).map((e) => e.event)).toEqual(['b', 'c'])++ expect(q.due(20).map((e) => e.event)).toEqual(['a'])++ })++})++++describe('classifyZone / buildTriggerZone', () => {++ it('recognises teleport, push, once and multiple volumes', () => {++ expect(classifyZone(zone({ teleport: { to: 'dest' } }))).toBe('trigger_teleport')++ expect(classifyZone(zone({ push: { dir: [0, 1, 0], speed: 100 } }))).toBe('trigger_push')++ expect(classifyZone(zone({ trigger: { once: true, outputs: [] } }))).toBe('trigger_once')++ expect(classifyZone(zone({ trigger: { outputs: [] } }))).toBe('trigger_multiple')++ expect(classifyZone(zone())).toBeNull()++ })++++ it('defaults trigger_multiple wait to the Source-like 0.2s', () => {++ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!++ expect(trigger.trigger!.wait).toBe(0.2)++ })++})++++describe('touchTransition', () => {++ it('fires start and end once per crossing, and reports through-crossing starts', () => {++ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!++ const start = touchTransition(trigger, p(0, 1, -2), p(0, 1, 0), false)++ expect(start.kind).toBe('start')++ expect(start.nowInside).toBe(true)++++ const stay = touchTransition(trigger, p(0, 1, 0), p(0.5, 1, 0), true)++ expect(stay.kind).toBe('none')++ expect(stay.nowInside).toBe(true)++++ const end = touchTransition(trigger, p(0, 1, 0), p(0, 1, 2), true)++ expect(end.kind).toBe('end')++ expect(end.nowInside).toBe(false)++ })++++ it('reports a start for a catch-up segment that passes all the way through', () => {++ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!++ const through = touchTransition(trigger, p(0, 1, -2), p(0, 1, 2), false)++ expect(through.kind).toBe('start')++ })++})++++describe('TriggerGraph', () => {++ it('fires logic_auto OnMapSpawn edges on the first tick', () => {++ const t = zone({ id: 'vol', trigger: { outputs: [] } })++ const graph = new TriggerGraph({++ zones: [t],++ points: [],++ logic: [++ { id: 'auto1', class: 'auto', outputs: [{ event: 'OnMapSpawn', target: 'vol', input: 'Disable' }] },++ ],++ tickRate: 100,++ })++ expect(graph.triggers[0]!.enabled).toBe(true)++ graph.tick(0)++ expect(graph.triggers[0]!.enabled).toBe(false)++ })++++ it('makes logic_relay delays tick-deterministic', () => {++ const t = zone({ id: 'vol', trigger: { outputs: [] } })++ const graph = new TriggerGraph({++ zones: [t],++ points: [],++ logic: [++ {++ id: 'relay',++ class: 'relay',++ outputs: [{ event: 'OnTrigger', target: 'vol', input: 'Disable', delay: 0.2 }],++ },++ ],++ tickRate: 100,++ })++ graph.fireInput('relay', 'Trigger', undefined, 0)++ graph.drain(19)++ expect(graph.triggers[0]!.enabled).toBe(true)++ graph.drain(20)++ expect(graph.triggers[0]!.enabled).toBe(false)++ })++})nnew file mode 100644@@@ -0,0 +1,263 @@++import type { LogicDef, PointDef, ZoneDef } from '@openvibe/content'++import { insideBox, segmentAabbEntry, segmentAabbExit } from '../surf/run.js'++import { OutputQueue, outputKey, type OutputSpec } from './io.js'++import {++ buildLogicEntity,++ fireLogicInput,++ timerIntervalTicks,++ type LogicEntity,++} from './logic.js'++++/**++ * Runtime trigger entities. These are engine-free: the server feeds them++ * authoritative movement segments, and the same shape can be consumed by a++ * 2D/top-down presentation without a physics body anywhere in the model.++ */++++export type TriggerKind =++ | 'trigger_multiple'++ | 'trigger_once'++ | 'trigger_teleport'++ | 'trigger_push'++++export interface Vec3 {++ x: number++ y: number++ z: number++}++++export interface Aabb {++ min: readonly [number, number, number]++ max: readonly [number, number, number]++}++++export interface TriggerZoneRuntime {++ id: string++ kind: TriggerKind++ min: [number, number, number]++ max: [number, number, number]++ trigger?: { wait: number; once: boolean; outputs: OutputSpec[] }++ push?: { dir: [number, number, number]; speed: number; onlyFalling: boolean }++ teleport?: { to: string; landmark?: string; keepAngles: boolean }++ enabled: boolean++}++++export interface PointEntity {++ id: string++ pos: Vec3++ yaw: number++ class: 'target' | 'teleport_destination' | 'camera'++}++++export const DEFAULT_MULTIPLE_WAIT = 0.2++++export function classifyZone(zone: ZoneDef): TriggerKind | null {++ if (zone.teleport) return 'trigger_teleport'++ if (zone.push) return 'trigger_push'++ if (zone.trigger) return zone.trigger.once ? 'trigger_once' : 'trigger_multiple'++ return null++}++++export function buildTriggerZone(zone: ZoneDef): TriggerZoneRuntime | null {++ const kind = classifyZone(zone)++ if (!kind) return null++ return {++ id: zone.id,++ kind,++ min: [zone.min[0], zone.min[1], zone.min[2]],++ max: [zone.max[0], zone.max[1], zone.max[2]],++ ...(zone.trigger++ ? {++ trigger: {++ wait: zone.trigger.wait ?? (zone.trigger.once ? 0 : DEFAULT_MULTIPLE_WAIT),++ once: zone.trigger.once ?? false,++ outputs: zone.trigger.outputs.map((o) => ({ ...o })),++ },++ }++ : {}),++ ...(zone.push++ ? {++ push: {++ dir: [zone.push.dir[0], zone.push.dir[1], zone.push.dir[2]],++ speed: zone.push.speed,++ onlyFalling: zone.push.onlyFalling ?? false,++ },++ }++ : {}),++ ...(zone.teleport++ ? {++ teleport: {++ to: zone.teleport.to,++ ...(zone.teleport.landmark !== undefined++ ? { landmark: zone.teleport.landmark }++ : {}),++ keepAngles: zone.teleport.keepAngles ?? false,++ },++ }++ : {}),++ enabled: true,++ }++}++++export function buildPointEntity(point: PointDef): PointEntity {++ return {++ id: point.id,++ pos: { x: point.pos[0], y: point.pos[1], z: point.pos[2] },++ yaw: point.yaw ?? 0,++ class: point.class,++ }++}++++/** Outputs whose `event` matches a touch/trigger event name. */++export function outputsFor(trigger: TriggerZoneRuntime, event: string): OutputSpec[] {++ return (trigger.trigger?.outputs ?? []).filter((o) => o.event === event)++}++++export type TouchTransition =++ | { kind: 'start'; frac: number; nowInside: boolean }++ | { kind: 'end'; frac: number; nowInside: boolean }++ | { kind: 'none'; nowInside: boolean }++++/**++ * What one movement segment did to one trigger volume. Entry uses the slab++ * test, so a segment that starts outside and ends outside still reports a++ * start if it passed through the box — the catch-up guarantee.++ */++export function touchTransition(++ trigger: TriggerZoneRuntime,++ from: Vec3,++ to: Vec3,++ wasInside: boolean,++): TouchTransition {++ const box: Aabb = { min: trigger.min, max: trigger.max }++ if (wasInside) {++ if (!insideBox(box, to)) {++ const frac = segmentAabbExit(from, to, box) ?? 1++ return { kind: 'end', frac, nowInside: false }++ }++ return { kind: 'none', nowInside: true }++ }++ const entry = segmentAabbEntry(from, to, box)++ if (entry !== null) return { kind: 'start', frac: entry, nowInside: insideBox(box, to) }++ return { kind: 'none', nowInside: insideBox(box, to) }++}++++export interface TriggerGraphInput {++ zones: readonly ZoneDef[]++ points: readonly PointDef[]++ logic: readonly LogicDef[]++ tickRate: number++}++++const MAX_DRAIN_ITERATIONS = 10_000++++/**++ * The whole map's trigger graph: trigger volumes, point entities, logic++ * entities and the one deterministic queue that connects them.++ *++ * `drain` fires every edge due at the current tick, including delay-0 edges++ * scheduled by an earlier edge in the same tick, so a relay chain resolves++ * immediately and deterministically.++ */++export class TriggerGraph {++ readonly triggers: TriggerZoneRuntime[]++ readonly points = new Map<string, PointEntity>()++ private readonly logic = new Map<string, LogicEntity>()++ private readonly queue = new OutputQueue()++ private readonly tickRate: number++ private readonly firedOnce = new Set<string>()++ private started = false++++ constructor(input: TriggerGraphInput) {++ this.tickRate = input.tickRate++ this.triggers = input.zones++ .map(buildTriggerZone)++ .filter((t): t is TriggerZoneRuntime => t !== null)++ for (const point of input.points) {++ const p = buildPointEntity(point)++ this.points.set(p.id, p)++ }++ for (const def of input.logic) {++ const l = buildLogicEntity(def)++ this.logic.set(l.id, l)++ }++ }++++ /** Seed `logic_auto` OnMapSpawn edges and initial timers, once. */++ begin(nowTick: number): void {++ if (this.started) return++ this.started = true++ for (const l of this.logic.values()) {++ if (l.class !== 'auto') continue++ this.schedule(l.outputs, nowTick)++ }++ }++++ /** Fire every edge due at `nowTick`, following delay-0 chains. */++ drain(nowTick: number): void {++ for (let i = 0; i < MAX_DRAIN_ITERATIONS; i++) {++ const due = this.queue.due(nowTick)++ if (due.length === 0) return++ for (const edge of due) this.dispatch(edge.target, edge.input, edge.param, nowTick)++ }++ }++++ /** One tick: seed auto, fire due timers, then drain. */++ tick(nowTick: number): void {++ this.begin(nowTick)++ for (const l of this.logic.values()) {++ if (l.class !== 'timer' || !l.enabled) continue++ if (l.nextFireTick === null || l.nextFireTick > nowTick) continue++ this.schedule(l.outputs, nowTick)++ l.nextFireTick = nowTick + timerIntervalTicks(l, this.tickRate)++ }++ this.drain(nowTick)++ }++++ /** Schedule outputs from a touch event (does not drain; call drain after). */++ fireTouchOutputs(trigger: TriggerZoneRuntime, event: string, nowTick: number): void {++ this.schedule(outputsFor(trigger, event), nowTick)++ }++++ /** Send an input to a named entity; schedules whatever it returns. */++ fireInput(target: string, input: string, param: string | undefined, nowTick: number): void {++ this.dispatch(target, input, param, nowTick)++ }++++ /** Schedule edges, honouring `once` and dropping unknown targets. */++ private schedule(outputs: readonly OutputSpec[], nowTick: number): void {++ const pending: OutputSpec[] = []++ for (const output of outputs) {++ const key = outputKey(output)++ if (output.once && this.firedOnce.has(key)) continue++ if (output.once) this.firedOnce.add(key)++ pending.push(output)++ }++ this.queue.schedule(pending, nowTick, this.tickRate)++ }++++ private dispatch(target: string, input: string, param: string | undefined, nowTick: number): void {++ const logic = this.logic.get(target)++ if (logic) {++ this.schedule(fireLogicInput(logic, input, nowTick, this.tickRate), nowTick)++ return++ }++ const trigger = this.triggers.find((t) => t.id === target)++ if (!trigger) return++ if (input === 'Enable') {++ trigger.enabled = true++ return++ }++ if (input === 'Disable') {++ trigger.enabled = false++ return++ }++ if (input === 'Toggle') {++ trigger.enabled = !trigger.enabled++ return++ }++ if (input === 'Trigger') {++ this.schedule(outputsFor(trigger, 'OnTrigger'), nowTick)++ }++ }++}@@@ -9,6 +9,9 @@ export * from './movement/simulate.js'export * from './movement/styles.js'export * from './surf/run.js'export * from './surf/ghost.js'++export * from './entities/io.js'++export * from './entities/trigger.js'++export * from './entities/logic.js'export * from './skills/skills.js'export * from './zones/zones.js'export * from './entity/entity.js'@@@ -151,6 +151,22 @@ export function segmentAabbExit(a: SurfVec3, b: SurfVec3, box: SurfBox): numberreturn tExit >= 0 && tExit <= 1 ? tExit : null}++/**++ * Source surf timer zone aliases. `surf.kind` stays the canonical form; a++ * map authored with the interoperable trigger names (`map_start`, `map_cpN`,++ * `map_end`, `map_kill`) still builds a run without duplicating the kind.++ */++export function surfAliasFor(++ idOrName: string,++): { kind: SurfZoneKind; stage?: number } | null {++ if (idOrName === 'map_start') return { kind: 'start', stage: 0 }++ if (idOrName === 'map_end') return { kind: 'finish' }++ if (idOrName === 'map_kill') return { kind: 'kill', stage: 0 }++ const cp = /^map_cp(\d+)$/.exec(idOrName)++ if (cp) return { kind: 'checkpoint', stage: Number(cp[1]) }++ return null++}++/** Build the run description from a compiled map's surf metadata and zones. */export function surfRunMapOf(meta: SurfMapMeta | undefined,@@@ -159,25 +175,39 @@ export function surfRunMapOf(spawnYaw = 0,id = meta?.title ?? 'surf',): SurfRunMap {-- const tagged = zones.filter((z) => z.surf)-- const start = tagged.find((z) => z.surf!.kind === 'start')-- const finish = tagged.find((z) => z.surf!.kind === 'finish')++ const tagged: { zone: ZoneDef; meta: NonNullable<ZoneDef['surf']> }[] = []++ for (const zone of zones) {++ if (zone.surf) {++ tagged.push({ zone, meta: zone.surf })++ continue++ }++ const alias = surfAliasFor(zone.id) ?? surfAliasFor(zone.name)++ if (!alias) continue++ const stage = alias.kind === 'finish'++ ? (meta?.stages ?? 1)++ : alias.kind === 'checkpoint'++ ? (alias.stage ?? 0)++ : 0++ tagged.push({ zone, meta: { kind: alias.kind, stage } })++ }++ const start = tagged.find((t) => t.meta.kind === 'start')++ const finish = tagged.find((t) => t.meta.kind === 'finish')if (!start || !finish)throw new Error('surf map needs a start zone and a finish zone')-- const toZone = (z: ZoneDef): SurfRunZone => ({-- id: z.id,-- kind: z.surf!.kind,-- stage: z.surf!.stage,-- min: [z.min[0], z.min[1], z.min[2]],-- max: [z.max[0], z.max[1], z.max[2]],-- ...(z.surf!.spawn-- ? { spawn: { x: z.surf!.spawn[0], y: z.surf!.spawn[1], z: z.surf!.spawn[2] } }++ const toZone = (t: (typeof tagged)[number]): SurfRunZone => ({++ id: t.zone.id,++ kind: t.meta.kind,++ stage: t.meta.stage,++ min: [t.zone.min[0], t.zone.min[1], t.zone.min[2]],++ max: [t.zone.max[0], t.zone.max[1], t.zone.max[2]],++ ...(t.meta.spawn++ ? { spawn: { x: t.meta.spawn[0], y: t.meta.spawn[1], z: t.meta.spawn[2] } }: {}),-- ...(z.surf!.yaw !== undefined ? { yaw: z.surf!.yaw } : {}),++ ...(t.meta.yaw !== undefined ? { yaw: t.meta.yaw } : {}),})const checkpoints = tagged-- .filter((z) => z.surf!.kind === 'checkpoint')-- .sort((a, b) => a.surf!.stage - b.surf!.stage)++ .filter((t) => t.meta.kind === 'checkpoint')++ .sort((a, b) => a.meta.stage - b.meta.stage).map(toZone)return {id,@@@ -186,7 +216,7 @@ export function surfRunMapOf(start: toZone(start),checkpoints,finish: toZone(finish),-- kills: tagged.filter((z) => z.surf!.kind === 'kill').map(toZone),++ kills: tagged.filter((t) => t.meta.kind === 'kill').map(toZone),spawn: { x: spawn[0], y: spawn[1], z: spawn[2] },spawnYaw,}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/game/systems/triggers.test.ts'++// covers: 'apps/server/src/game/systems'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/gameplay/src/entities/trigger.test.ts'++// covers: 'packages/gameplay/src/entities'++// Generated by node test/run.js --index; do not edit.
@@ -21,7 +21,7 @@ export const newId = (prefix: string): string =>
@@ -21,7 +21,7 @@ export const newId = (prefix: string): string =>
export interface Placeable {
export interface Placeable {
name: string
name: string
- kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc'
+ kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc' | 'trigger'
shape?: StaticBody['shape']
shape?: StaticBody['shape']
color?: string
color?: string
tex?: string
tex?: string
@@ -69,6 +69,7 @@ export const ENTITY_DEFS: Placeable[] = [
@@ -69,6 +69,7 @@ export const ENTITY_DEFS: Placeable[] = [
{ name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },
{ name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },
{ name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },
{ name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },
{ name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },
{ name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },
+ { name: '🔘 Trigger volume', kind: 'trigger' },
]
]
export const TEXTURES = [
export const TEXTURES = [
@@ -58,7 +58,7 @@ import { ToolManager } from './tools/toolManager.js'
@@ -58,7 +58,7 @@ import { ToolManager } from './tools/toolManager.js'
import { PlacementTool, objectForPlacement } from './tools/placementTool.js'
import { PlacementTool, objectForPlacement } from './tools/placementTool.js'
import { sculptDab, toTerrainLocal, type SculptMode } from './tools/terrainTool.js'
import { sculptDab, toTerrainLocal, type SculptMode } from './tools/terrainTool.js'
import { lightForPlacement, type LightType } from './tools/lightTool.js'
import { lightForPlacement, type LightType } from './tools/lightTool.js'
-import { zoneForPlacement } from './tools/zoneTool.js'
+import { triggerForPlacement, zoneForPlacement } from './tools/zoneTool.js'
import { EditorCameraController } from './viewport/editorCameraController.js'
import { EditorCameraController } from './viewport/editorCameraController.js'
import { EditorViewRegistry } from './viewport/editorViewRegistry.js'
import { EditorViewRegistry } from './viewport/editorViewRegistry.js'
import { GizmoController, type GizmoMode } from './viewport/gizmoController.js'
import { GizmoController, type GizmoMode } from './viewport/gizmoController.js'
@@ -662,16 +662,20 @@ export async function bootEditor(): Promise<void> {
@@ -662,16 +662,20 @@ export async function bootEditor(): Promise<void> {
const placeZone = (): void => {
const placeZone = (): void => {
const { point } = creationPoint()
const { point } = creationPoint()
- const zone = zoneForPlacement({
- point,
- size: Number((document.getElementById('zone-size') as HTMLInputElement).value),
- height: Number((document.getElementById('zone-height') as HTMLInputElement).value),
- taken: (id) => doc.has(id),
- })
- history.apply(addObject('zone', zone as never, 'create zone'))
+ const isTrigger = (document.getElementById('zone-trigger') as HTMLInputElement | null)?.checked
+ const size = Number((document.getElementById('zone-size') as HTMLInputElement).value)
+ const height = Number((document.getElementById('zone-height') as HTMLInputElement).value)
+ const zone = isTrigger
+ ? triggerForPlacement({ point, size, height, taken: (id) => doc.has(id) })
+ : zoneForPlacement({ point, size, height, taken: (id) => doc.has(id) })
+ history.apply(addObject('zone', zone as never, isTrigger ? 'create trigger' : 'create zone'))
selection.replace(zone.id)
selection.replace(zone.id)
ui.refreshAll()
ui.refreshAll()
- ui.setMessage(`🟦 ${zone.name} created — scale it with the gizmo, set rules in the Inspector`)
+ ui.setMessage(
+ isTrigger
+ ? `🔘 ${zone.name} created — set outputs in the Inspector`
+ : `🟦 ${zone.name} created — scale it with the gizmo, set rules in the Inspector`,
+ )
}
}
// ── Face tool ───────────────────────────────────────────────────────
// ── Face tool ───────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { MapZoneSchemaV2, MapLightSchemaV2 } from '@openvibe/content'
import { MapZoneSchemaV2, MapLightSchemaV2 } from '@openvibe/content'
import { lightForPlacement, type LightType } from './lightTool.js'
import { lightForPlacement, type LightType } from './lightTool.js'
-import { nextZoneId, zoneForPlacement } from './zoneTool.js'
+import { nextZoneId, triggerForPlacement, zoneForPlacement } from './zoneTool.js'
const place = (type: LightType) =>
const place = (type: LightType) =>
lightForPlacement({
lightForPlacement({
@@ -96,3 +96,12 @@ describe('zoneForPlacement', () => {
@@ -96,3 +96,12 @@ describe('zoneForPlacement', () => {
expect(nextZoneId((id) => taken.has(id))).toBe('zone-3')
expect(nextZoneId((id) => taken.has(id))).toBe('zone-3')
})
})
})
})
+
+describe('triggerForPlacement', () => {
+ it('produces a trigger_multiple zone the schema accepts', () => {
+ const z = triggerForPlacement({ point: [0, 1, 0], size: 4, height: 3, taken: () => false })
+ const parsed = MapZoneSchemaV2.safeParse(z)
+ expect(parsed.success, parsed.success ? '' : parsed.error.message).toBe(true)
+ if (parsed.success) expect(parsed.data.trigger).toMatchObject({ wait: 0.2, outputs: [] })
+ })
+})
@@ -54,6 +54,7 @@ export function placeableShape(
@@ -54,6 +54,7 @@ export function placeableShape(
const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]
const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]
return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }
return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }
}
}
+ if (def.kind === 'trigger') return { type: 'box', size: [2, 2, 2] }
return { type: 'box', size: [0.3, 3, 0.3] } // spawn flag pole
return { type: 'box', size: [0.3, 3, 0.3] } // spawn flag pole
}
}
@@ -234,6 +235,18 @@ export function objectForPlacement(
@@ -234,6 +235,18 @@ export function objectForPlacement(
kind: 'spawn',
kind: 'spawn',
object: { id: 'spawn', pos: [pose.pos.x, pose.pos.y, pose.pos.z], yaw: e.y },
object: { id: 'spawn', pos: [pose.pos.x, pose.pos.y, pose.pos.z], yaw: e.y },
}
}
+ case 'trigger':
+ return {
+ kind: 'zone',
+ object: {
+ id: newId('trigger'),
+ name: 'Trigger',
+ min: [pose.pos.x - 1, pose.pos.y, pose.pos.z - 1],
+ max: [pose.pos.x + 1, pose.pos.y + 2, pose.pos.z + 1],
+ rules: { pvp: true, build: true, physgun: true },
+ trigger: { wait: 0.2, outputs: [] },
+ },
+ }
case 'patch': {
case 'patch': {
const sub = 32
const sub = 32
return {
return {
@@ -51,3 +51,27 @@ export function zoneForPlacement(p: ZonePlacement): MapZoneV2 {
@@ -51,3 +51,27 @@ export function zoneForPlacement(p: ZonePlacement): MapZoneV2 {
rules: { pvp: true, build: true, physgun: true },
rules: { pvp: true, build: true, physgun: true },
}
}
}
}
+
+/** Next readable trigger id (`trigger-1`, `trigger-2`, …). */
+export function nextTriggerId(taken: (id: string) => boolean): string {
+ for (let n = 1; n < 10_000; n++) {
+ const id = `trigger-${n}`
+ if (!taken(id)) return id
+ }
+ return `trigger-${Date.now().toString(36)}`
+}
+
+/** A `trigger_multiple` zone, ready for the Inspector's outputs field. */
+export function triggerForPlacement(p: ZonePlacement): MapZoneV2 {
+ const id = nextTriggerId(p.taken)
+ const half = Math.max(0.5, p.size / 2)
+ const height = Math.max(0.5, p.height)
+ return {
+ id,
+ name: `Trigger ${id.replace('trigger-', '')}`,
+ min: [p.point[0] - half, p.point[1], p.point[2] - half],
+ max: [p.point[0] + half, p.point[1] + height, p.point[2] + half],
+ rules: { pvp: true, build: true, physgun: true },
+ trigger: { wait: 0.2, outputs: [] },
+ }
+}
@@ -243,6 +243,7 @@ export function buildShell(
@@ -243,6 +243,7 @@ export function buildShell(
zone.append(
zone.append(
labelled('Size', slider('zone-size', 2, 80, 1, 16)),
labelled('Size', slider('zone-size', 2, 80, 1, 16)),
labelled('Height', slider('zone-height', 2, 60, 1, 12)),
labelled('Height', slider('zone-height', 2, 60, 1, 12)),
+ labelled('Trigger', checkbox('zone-trigger')),
)
)
const face = el('div', 'strip', 'face-strip')
const face = el('div', 'strip', 'face-strip')
@@ -59,6 +59,20 @@ describe('fieldsFor', () => {
@@ -59,6 +59,20 @@ describe('fieldsFor', () => {
)
)
})
})
+ it('gives a zone the trigger, push and teleport authoring fields', () => {
+ const f = keys(fieldsFor('zone', null))
+ expect(f).toEqual(
+ expect.arrayContaining([
+ 'trigger.once',
+ 'trigger.wait',
+ 'trigger.outputs',
+ 'push.dir',
+ 'push.speed',
+ 'teleport.to',
+ ]),
+ )
+ })
+
it('omits rotation for a resource node, which has no facing', () => {
it('omits rotation for a resource node, which has no facing', () => {
expect(keys(fieldsFor('node', null))).not.toContain('rot')
expect(keys(fieldsFor('node', null))).not.toContain('rot')
})
})
@@ -224,6 +238,27 @@ describe('Inspector DOM', () => {
@@ -224,6 +238,27 @@ describe('Inspector DOM', () => {
expect(doc.get('s1', 'static')!.pos[0]).toBe(1)
expect(doc.get('s1', 'static')!.pos[0]).toBe(1)
})
})
+ it('parses the JSON outputs field before committing', () => {
+ const doc = new EditorDocument()
+ doc.add('zone', {
+ id: 'z1',
+ name: 'Z',
+ min: [0, 0, 0],
+ max: [1, 1, 1],
+ rules: { pvp: true, build: true, physgun: true },
+ trigger: { wait: 0.2, outputs: [] },
+ } as never)
+ new Inspector(root, doc, callbacks).setState({ ids: ['z1'], lockedBy: null })
+ const el = root.querySelector('[data-field="trigger.outputs"]') as HTMLInputElement
+ el.value = '[{"event":"OnStartTouch","target":"r1","input":"Trigger"}]'
+ el.dispatchEvent(new Event('change'))
+ expect(callbacks.setProperty).toHaveBeenCalledWith(
+ ['z1'],
+ 'trigger.outputs',
+ [{ event: 'OnStartTouch', target: 'r1', input: 'Trigger' }],
+ )
+ })
+
it('ignores unparseable numeric input and restores the true value', () => {
it('ignores unparseable numeric input and restores the true value', () => {
withStatic()
withStatic()
const x = root.querySelector('[data-field="pos"][data-component="0"]') as HTMLInputElement
const x = root.querySelector('[data-field="pos"][data-component="0"]') as HTMLInputElement
@@ -18,7 +18,14 @@ import type { EditorDocument, EditorObject, EditorObjectKind } from '../document
@@ -18,7 +18,14 @@ import type { EditorDocument, EditorObject, EditorObjectKind } from '../document
import { KIND_INFO } from '../document/editorObject.js'
import { KIND_INFO } from '../document/editorObject.js'
import { commonValue, formatNumber } from './numberField.js'
import { commonValue, formatNumber } from './numberField.js'
-export type FieldType = 'number' | 'text' | 'color' | 'checkbox' | 'select' | 'readonly'
+export type FieldType =
+ | 'number'
+ | 'text'
+ | 'color'
+ | 'checkbox'
+ | 'select'
+ | 'readonly'
+ | 'json'
export interface FieldSpec {
export interface FieldSpec {
key: string
key: string
@@ -230,6 +237,14 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):
@@ -230,6 +237,14 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):
{ key: 'rules.pvp', label: 'PVP', type: 'checkbox', section: 'Rules' },
{ key: 'rules.pvp', label: 'PVP', type: 'checkbox', section: 'Rules' },
{ key: 'rules.build', label: 'Building', type: 'checkbox', section: 'Rules' },
{ key: 'rules.build', label: 'Building', type: 'checkbox', section: 'Rules' },
{ key: 'rules.physgun', label: 'Physgun', type: 'checkbox', section: 'Rules' },
{ key: 'rules.physgun', label: 'Physgun', type: 'checkbox', section: 'Rules' },
+ { key: 'trigger.once', label: 'Fire once', type: 'checkbox', section: 'Trigger' },
+ { key: 'trigger.wait', label: 'Wait (s)', type: 'number', step: 0.05, min: 0, section: 'Trigger' },
+ { key: 'trigger.outputs', label: 'Outputs (JSON)', type: 'json', section: 'Trigger' },
+ { key: 'push.dir', label: 'Push dir', type: 'number', vector: 3, section: 'Push' },
+ { key: 'push.speed', label: 'Push speed (u/s)', type: 'number', step: 1, min: 0, section: 'Push' },
+ { key: 'push.onlyFalling', label: 'Only falling', type: 'checkbox', section: 'Push' },
+ { key: 'teleport.to', label: 'Teleport target', type: 'text', section: 'Teleport' },
+ { key: 'teleport.keepAngles', label: 'Keep angles', type: 'checkbox', section: 'Teleport' },
]
]
}
}
}
}
@@ -278,6 +293,10 @@ export function displayValue(
@@ -278,6 +293,10 @@ export function displayValue(
const bools = raw.map((v) => v === true)
const bools = raw.map((v) => v === true)
return bools.every((b) => b === bools[0]) ? bools[0]! : null
return bools.every((b) => b === bools[0]) ? bools[0]! : null
}
}
+ if (field.type === 'json') {
+ const strings = raw.map((v) => (v === undefined || v === null ? '' : JSON.stringify(v)))
+ return strings.every((s) => s === strings[0]) ? strings[0]! : null
+ }
const strings = raw.map((v) => (v === undefined || v === null ? '' : String(v)))
const strings = raw.map((v) => (v === undefined || v === null ? '' : String(v)))
return strings.every((s) => s === strings[0]) ? strings[0]! : null
return strings.every((s) => s === strings[0]) ? strings[0]! : null
}
}
@@ -449,6 +468,16 @@ export class Inspector {
@@ -449,6 +468,16 @@ export class Inspector {
this.refreshValues()
this.refreshValues()
return
return
}
}
+ if (field.type === 'json') {
+ try {
+ const value = JSON.parse(input.value)
+ if (!Array.isArray(value)) throw new Error('not an array')
+ this.commit(field, value)
+ } catch {
+ this.refreshValues()
+ }
+ return
+ }
if (field.type !== 'number') {
if (field.type !== 'number') {
this.commit(field, input.value)
this.commit(field, input.value)
return
return
@@ -27,6 +27,7 @@ import {
@@ -27,6 +27,7 @@ import {
SettingsSystem,
SettingsSystem,
SocialSystem,
SocialSystem,
SurfSystem,
SurfSystem,
+ TriggersSystem,
VehicleSystem,
VehicleSystem,
WorldEventSystem,
WorldEventSystem,
buildHandlerTable,
buildHandlerTable,
@@ -90,6 +91,9 @@ export class GameServer {
@@ -90,6 +91,9 @@ export class GameServer {
// Runs see this tick's authoritative positions: construct after movement,
// Runs see this tick's authoritative positions: construct after movement,
// insert in the ordered list before replication.
// insert in the ordered list before replication.
const surf = new SurfSystem(ctx)
const surf = new SurfSystem(ctx)
+ // Triggers read the same authoritative segments and apply push/teleport
+ // before the next movement step.
+ const triggers = new TriggersSystem(ctx)
// The wire surface every system sends through.
// The wire surface every system sends through.
const replication = new ReplicationSystem(ctx)
const replication = new ReplicationSystem(ctx)
ctx.net = replication
ctx.net = replication
@@ -115,6 +119,7 @@ export class GameServer {
@@ -115,6 +119,7 @@ export class GameServer {
chat,
chat,
movement,
movement,
surf,
surf,
+ triggers,
replication,
replication,
manipulation,
manipulation,
combat,
combat,
@@ -141,6 +146,7 @@ export class GameServer {
@@ -141,6 +146,7 @@ export class GameServer {
chat,
chat,
movement,
movement,
surf,
surf,
+ triggers,
replication,
replication,
manipulation,
manipulation,
combat,
combat,
@@ -301,6 +307,9 @@ export class GameServer {
@@ -301,6 +307,9 @@ export class GameServer {
// 1b. Surf run state: timer, checkpoints, fails, finish (after movement,
// 1b. Surf run state: timer, checkpoints, fails, finish (after movement,
// before replication so surf_state travels in this tick's snapshot).
// before replication so surf_state travels in this tick's snapshot).
s.surf.tick(ctx, dt)
s.surf.tick(ctx, dt)
+ // 1c. Trigger/I/O: derive touch events from the same segments, then apply
+ // push/teleport so they take effect before the next movement step.
+ s.triggers.tick(ctx, dt)
if (this.rush) {
if (this.rush) {
const now = Date.now()
const now = Date.now()
const results = this.rush.tick(
const results = this.rush.tick(
@@ -107,6 +107,10 @@ export interface PlayerSession {
@@ -107,6 +107,10 @@ export interface PlayerSession {
surfPbSplits: number[]
surfPbSplits: number[]
/** This tick's authoritative movement segments (cleared by MovementSystem). */
/** This tick's authoritative movement segments (cleared by MovementSystem). */
moveSegments: { from: Vec3; to: Vec3; yaw: number }[]
moveSegments: { from: Vec3; to: Vec3; yaw: number }[]
+ /** Per-trigger inside state, so OnStartTouch/OnEndTouch fire once per crossing. */
+ triggerTouches: Map<string, boolean>
+ /** Last tick a trigger fired OnStartTouch, for `trigger_multiple`'s `wait`. */
+ triggerStartTicks: Map<string, number>
/** Tick of the last accepted use/swing (server-side swing cooldown). */
/** Tick of the last accepted use/swing (server-side swing cooldown). */
lastUseTick: number
lastUseTick: number
/** Stance the kinematic physics body was last built for. */
/** Stance the kinematic physics body was last built for. */
@@ -196,6 +200,8 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -196,6 +200,8 @@ export function createSession(init: SessionInit): PlayerSession {
surfWr: null,
surfWr: null,
surfPbSplits: [],
surfPbSplits: [],
moveSegments: [],
moveSegments: [],
+ triggerTouches: new Map(),
+ triggerStartTicks: new Map(),
lastUseTick: 0,
lastUseTick: 0,
bodyStance: 0,
bodyStance: 0,
content: init.content,
content: init.content,
@@ -30,6 +30,7 @@ import type { ReplicationSystem } from './replication.js'
@@ -30,6 +30,7 @@ import type { ReplicationSystem } from './replication.js'
import type { SessionsSystem } from './sessions.js'
import type { SessionsSystem } from './sessions.js'
import type { SettingsSystem } from './settings.js'
import type { SettingsSystem } from './settings.js'
import type { SurfSystem } from './surf.js'
import type { SurfSystem } from './surf.js'
+import type { TriggersSystem } from './triggers.js'
import type { SurfRecords } from '../surfRecords.js'
import type { SurfRecords } from '../surfRecords.js'
import type { SocialSystem } from './social.js'
import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
import type { VehicleSystem } from './vehicles.js'
@@ -197,6 +198,7 @@ export interface ServerSystems {
@@ -197,6 +198,7 @@ export interface ServerSystems {
chat: ChatSystem
chat: ChatSystem
movement: MovementSystem
movement: MovementSystem
surf: SurfSystem
surf: SurfSystem
+ triggers: TriggersSystem
replication: ReplicationSystem
replication: ReplicationSystem
manipulation: ManipulationSystem
manipulation: ManipulationSystem
combat: CombatSystem
combat: CombatSystem
@@ -20,6 +20,7 @@ export { SettingsSystem, canChangeServerCvars, SERVER_CVAR_ALLOWLIST } from './s
@@ -20,6 +20,7 @@ export { SettingsSystem, canChangeServerCvars, SERVER_CVAR_ALLOWLIST } from './s
export { ChatSystem } from '../chatCommands.js'
export { ChatSystem } from '../chatCommands.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { SurfSystem } from './surf.js'
export { SurfSystem } from './surf.js'
+export { TriggersSystem } from './triggers.js'
export { ReplicationSystem } from './replication.js'
export { ReplicationSystem } from './replication.js'
export { ManipulationSystem } from './manipulation.js'
export { ManipulationSystem } from './manipulation.js'
export { CombatSystem } from './combat.js'
export { CombatSystem } from './combat.js'
@@ -158,12 +158,14 @@ export class MovementSystem implements System {
@@ -158,12 +158,14 @@ export class MovementSystem implements System {
params: MovementParams,
params: MovementParams,
surfPlace: boolean,
surfPlace: boolean,
): void {
): void {
+ // Always clear, even while driving: TriggersSystem reads these segments
+ // for every room, and a stale previous-tick segment must never re-fire.
+ session.moveSegments.length = 0
if (session.driving) {
if (session.driving) {
ctx.systems.vehicles.driveVehicle(session)
ctx.systems.vehicles.driveVehicle(session)
return
return
}
}
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
- session.moveSegments.length = 0
let simulated = 0
let simulated = 0
for (const input of takeInputs(session, ctx.config.tickRate)) {
for (const input of takeInputs(session, ctx.config.tickRate)) {
session.lastInput = input
session.lastInput = input
@@ -171,16 +173,13 @@ export class MovementSystem implements System {
@@ -171,16 +173,13 @@ export class MovementSystem implements System {
session.lastProcessedSeq = input.seq
session.lastProcessedSeq = input.seq
applyLookInput(session, input)
applyLookInput(session, input)
session.buttons = input.intents
session.buttons = input.intents
- const from = surfPlace
- ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
- : null
+ const from = vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
stepMovement(session.move, toMoveInput(session, input), params, this.moveQueries, dt)
stepMovement(session.move, toMoveInput(session, input), params, this.moveQueries, dt)
- if (from)
- session.moveSegments.push({
- from,
- to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
- yaw: session.yaw,
- })
+ session.moveSegments.push({
+ from,
+ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
+ yaw: session.yaw,
+ })
simulated++
simulated++
}
}
if (simulated === 0 && session.lastInput) {
if (simulated === 0 && session.lastInput) {
@@ -191,9 +190,7 @@ export class MovementSystem implements System {
@@ -191,9 +190,7 @@ export class MovementSystem implements System {
if (session.starvedTicks > ctx.config.tickRate / 2) {
if (session.starvedTicks > ctx.config.tickRate / 2) {
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// (prone/crouch), and the server flapped stances.
// (prone/crouch), and the server flapped stances.
- const from = surfPlace
- ? vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
- : null
+ const from = vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z)
stepMovement(
stepMovement(
session.move,
session.move,
toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),
toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),
@@ -201,12 +198,11 @@ export class MovementSystem implements System {
@@ -201,12 +198,11 @@ export class MovementSystem implements System {
this.moveQueries,
this.moveQueries,
dt,
dt,
)
)
- if (from)
- session.moveSegments.push({
- from,
- to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
- yaw: session.yaw,
- })
+ session.moveSegments.push({
+ from,
+ to: vec3(session.move.pos.x, session.move.pos.y, session.move.pos.z),
+ yaw: session.yaw,
+ })
}
}
}
}
let bodyId = ctx.sessions.bodyOf(session.playerId)
let bodyId = ctx.sessions.bodyOf(session.playerId)
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { setMapOverride, type MapOverride, type ZoneDef } from '@openvibe/content'
+import { createMoveState } from '@openvibe/gameplay'
+import { vec3 } from '@openvibe/shared'
+import { describe, expect, it } from 'vitest'
+import { SOURCE_UNIT_TO_METRES, TriggersSystem } from './triggers.js'
+
+const world = { id: 'test-triggers', movement: 'default', zones: [] }
+
+function zone(id: string, over: Record<string, unknown> = {}): ZoneDef {
+ return {
+ id,
+ name: id,
+ min: [0, 0, 0],
+ max: [2, 2, 2],
+ rules: { pvp: true, build: true, physgun: true },
+ ...over,
+ } as unknown as ZoneDef
+}
+
+function override(): MapOverride {
+ return {
+ terrains: [],
+ zones: [
+ zone('start', {
+ min: [0, 0, 0],
+ max: [2, 2, 2],
+ trigger: {
+ wait: 0.2,
+ outputs: [
+ { event: 'OnStartTouch', target: 'flag', input: 'Disable' },
+ { event: 'OnEndTouch', target: 'flag', input: 'Enable' },
+ ],
+ },
+ }),
+ zone('flag', { min: [10, 0, 0], max: [12, 2, 2], trigger: { outputs: [] } }),
+ zone('tp', {
+ min: [20, 0, 0],
+ max: [22, 2, 2],
+ teleport: { to: 'dest', keepAngles: false },
+ }),
+ zone('boost', {
+ min: [30, 0, 0],
+ max: [32, 2, 2],
+ push: { dir: [0, 1, 0], speed: 800, onlyFalling: false },
+ }),
+ ],
+ points: [{ id: 'dest', pos: [5, 6, 7], yaw: 1.2, class: 'teleport_destination' }],
+ logic: [],
+ }
+}
+
+function sessionFor() {
+ const session = {
+ playerId: 'p1',
+ entityId: 'e1',
+ name: 'Tester',
+ yaw: 0,
+ pitch: 0,
+ move: createMoveState(vec3(0, 1, 0)),
+ moveSegments: [] as { from: { x: number; y: number; z: number }; to: { x: number; y: number; z: number }; yaw: number }[],
+ triggerTouches: new Map<string, boolean>(),
+ triggerStartTicks: new Map<string, number>(),
+ }
+ return session
+}
+
+function ctxFor(session: any, tick: number) {
+ return {
+ clock: { tick },
+ config: { tickRate: 100, placeId: 'test-triggers' },
+ world: {
+ content: { world },
+ bodyOf: () => undefined,
+ physics: { setTransform: () => {} },
+ entities: { get: () => undefined },
+ },
+ sessions: { values: () => [session][Symbol.iterator]() },
+ }
+}
+
+describe('TriggersSystem', () => {
+ it('fires OnStartTouch and OnEndTouch exactly once across a crossing', () => {
+ setMapOverride(override(), world.id)
+ try {
+ const session = sessionFor()
+ const system = new TriggersSystem(ctxFor(session, 0) as any)
+ const flag = (system as any).graph.triggers.find((t: any) => t.id === 'flag')
+
+ session.move.pos = vec3(0, 1, -2)
+ session.moveSegments.push({ from: vec3(0, 1, -2), to: vec3(0, 1, 0), yaw: 0 })
+ system.tick(ctxFor(session, 0) as any, 0.01)
+ expect(flag.enabled).toBe(false)
+
+ session.moveSegments.length = 0
+ session.move.pos = vec3(0, 1, 3)
+ session.moveSegments.push({ from: vec3(0, 1, 0), to: vec3(0, 1, 3), yaw: 0 })
+ system.tick(ctxFor(session, 1) as any, 0.01)
+ expect(flag.enabled).toBe(true)
+ } finally {
+ setMapOverride(null, world.id)
+ }
+ })
+
+ it('teleports to a destination, zeroing velocity and setting yaw', () => {
+ setMapOverride(override(), world.id)
+ try {
+ const session = sessionFor()
+ const system = new TriggersSystem(ctxFor(session, 0) as any)
+ session.move.pos = vec3(19, 1, 1)
+ session.move.vel.x = 12
+ session.move.vel.y = -3
+ session.moveSegments.push({ from: vec3(19, 1, 1), to: vec3(21, 1, 1), yaw: 0.4 })
+ system.tick(ctxFor(session, 0) as any, 0.01)
+ expect(session.move.pos.x).toBeCloseTo(5)
+ expect(session.move.pos.y).toBeCloseTo(6)
+ expect(session.move.pos.z).toBeCloseTo(7)
+ expect(session.move.vel).toEqual(vec3(0, 0, 0))
+ expect(session.yaw).toBeCloseTo(1.2)
+ } finally {
+ setMapOverride(null, world.id)
+ }
+ })
+
+ it('applies a push velocity of speed × 0.0254 m/s', () => {
+ setMapOverride(override(), world.id)
+ try {
+ const session = sessionFor()
+ const system = new TriggersSystem(ctxFor(session, 0) as any)
+ session.move.pos = vec3(29, 1, 1)
+ session.moveSegments.push({ from: vec3(29, 1, 1), to: vec3(31, 1, 1), yaw: 0 })
+ system.tick(ctxFor(session, 0) as any, 0.01)
+ expect(session.move.vel.y).toBeCloseTo(800 * SOURCE_UNIT_TO_METRES)
+ expect(session.move.vel.x).toBe(0)
+ expect(session.move.vel.z).toBe(0)
+ } finally {
+ setMapOverride(null, world.id)
+ }
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+import { getMapOverride } from '@openvibe/content'
+import {
+ placeMoveStateAt,
+ touchTransition,
+ TriggerGraph,
+ type TriggerZoneRuntime,
+} from '@openvibe/gameplay'
+import { qfromYaw, vec3 } from '@openvibe/shared'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * Source unit → metre conversion for authored movement values. 1 gameplay
+ * unit = 0.0254 m (see the movement research; do not use the 16 u/ft
+ * architecture scale here).
+ */
+export const SOURCE_UNIT_TO_METRES = 0.0254
+
+/**
+ * Deterministic trigger/I/O execution. It runs AFTER MovementSystem (so it
+ * sees this tick's authoritative segments) and AFTER SurfSystem (so surf's
+ * own fail/checkpoint teleports stay authoritative for a surf place). Push
+ * and teleport are applied here, so they land before the next movement step.
+ */
+export class TriggersSystem implements System {
+ readonly name = 'triggers'
+ private readonly graph: TriggerGraph
+
+ constructor(private readonly ctx: ServerContext) {
+ const override = getMapOverride(ctx.world.content.world)
+ this.graph = new TriggerGraph({
+ zones: override?.zones ?? [],
+ points: override?.points ?? [],
+ logic: override?.logic ?? [],
+ tickRate: ctx.config.tickRate,
+ })
+ }
+
+ tick(ctx: ServerContext, _dt: number): void {
+ const now = ctx.clock.tick
+ this.graph.tick(now)
+ for (const session of ctx.sessions.values()) this.stepSession(ctx, session, now)
+ // Delay-0 edges scheduled by a touch event fire in this same tick.
+ this.graph.drain(now)
+ }
+
+ private stepSession(ctx: ServerContext, session: PlayerSession, now: number): void {
+ for (const segment of session.moveSegments) {
+ for (const trigger of this.graph.triggers) {
+ if (!trigger.enabled) continue
+ const wasInside = session.triggerTouches.get(trigger.id) ?? false
+ const transition = touchTransition(trigger, segment.from, segment.to, wasInside)
+ session.triggerTouches.set(trigger.id, transition.nowInside)
+ if (transition.kind === 'start') {
+ this.fireStart(session, trigger, now)
+ this.applyTeleport(ctx, session, trigger)
+ } else if (transition.kind === 'end') {
+ this.graph.fireTouchOutputs(trigger, 'OnEndTouch', now)
+ }
+ if (transition.nowInside) this.applyPush(session, trigger)
+ }
+ }
+ }
+
+ private fireStart(session: PlayerSession, trigger: TriggerZoneRuntime, now: number): void {
+ if (!trigger.trigger) return
+ const waitTicks = Math.round(trigger.trigger.wait * this.ctx.config.tickRate)
+ const last = session.triggerStartTicks.get(trigger.id) ?? Number.NEGATIVE_INFINITY
+ if (waitTicks > 0 && now - last < waitTicks) return
+ session.triggerStartTicks.set(trigger.id, now)
+ this.graph.fireTouchOutputs(trigger, 'OnStartTouch', now)
+ if (trigger.trigger.once) trigger.enabled = false
+ }
+
+ private applyTeleport(ctx: ServerContext, session: PlayerSession, trigger: TriggerZoneRuntime): void {
+ if (trigger.kind !== 'trigger_teleport' || !trigger.teleport) return
+ const dest = this.graph.points.get(trigger.teleport.to)
+ if (!dest) return
+ const yaw = trigger.teleport.keepAngles ? session.yaw : dest.yaw
+ this.teleport(ctx, session, dest.pos, yaw)
+ }
+
+ private teleport(
+ ctx: ServerContext,
+ session: PlayerSession,
+ pos: { x: number; y: number; z: number },
+ yaw: number,
+ ): void {
+ placeMoveStateAt(session.move, pos)
+ session.yaw = yaw
+ const body = ctx.world.bodyOf(session.entityId)
+ if (body !== undefined)
+ ctx.world.physics.setTransform(body, vec3(pos.x, pos.y + 0.15, pos.z))
+ const entity = ctx.world.entities.get(session.entityId)
+ if (entity) qfromYaw(entity.transform.rot, yaw)
+ }
+
+ private applyPush(session: PlayerSession, trigger: TriggerZoneRuntime): void {
+ if (trigger.kind !== 'trigger_push' || !trigger.push) return
+ if (trigger.push.onlyFalling && session.move.grounded) return
+ const [dx, dy, dz] = trigger.push.dir
+ const len = Math.hypot(dx, dy, dz) || 1
+ const m = trigger.push.speed * SOURCE_UNIT_TO_METRES
+ session.move.vel.x = (dx / len) * m
+ session.move.vel.y = (dy / len) * m
+ session.move.vel.z = (dz / len) * m
+ }
+}
@@ -1060,6 +1060,8 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
@@ -1060,6 +1060,8 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
],
],
models: [],
models: [],
textures: [],
textures: [],
+ points: [],
+ logic: [],
spawn: [ISLAND.spawn[0], round(top(ISLAND.spawn[0], ISLAND.spawn[2])), ISLAND.spawn[2]],
spawn: [ISLAND.spawn[0], round(top(ISLAND.spawn[0], ISLAND.spawn[2])), ISLAND.spawn[2]],
spawnYaw: 0,
spawnYaw: 0,
}
}
@@ -449,6 +449,86 @@ describe('map-authored zones', () => {
@@ -449,6 +449,86 @@ describe('map-authored zones', () => {
})
})
})
})
+describe('trigger/I/O round trip', () => {
+ const mapWithIo = (): MapFileV2 => ({
+ ...emptyMapV2(),
+ zones: [
+ {
+ id: 'start',
+ name: 'Start',
+ min: [-2, 0, -2],
+ max: [2, 3, 2],
+ rules: { pvp: true, build: true, physgun: true },
+ trigger: {
+ wait: 0.2,
+ outputs: [{ event: 'OnStartTouch', target: 'relay', input: 'Trigger' }],
+ },
+ },
+ {
+ id: 'boost',
+ name: 'Boost',
+ min: [10, 0, 10],
+ max: [12, 3, 12],
+ rules: { pvp: true, build: true, physgun: true },
+ push: { dir: [0, 1, 0], speed: 800 },
+ },
+ {
+ id: 'tp',
+ name: 'Teleport',
+ min: [20, 0, 20],
+ max: [22, 3, 22],
+ rules: { pvp: true, build: true, physgun: true },
+ teleport: { to: 'dest', keepAngles: false },
+ },
+ ],
+ points: [{ id: 'dest', pos: [0, 1, 0], yaw: 1.5, class: 'teleport_destination' }],
+ logic: [
+ { id: 'relay', class: 'relay', outputs: [{ event: 'OnTrigger', target: 'boost', input: 'Enable' }] },
+ { id: 'auto1', class: 'auto', outputs: [{ event: 'OnMapSpawn', target: 'relay', input: 'Trigger' }] },
+ ],
+ })
+
+ it('parses, validates and compiles points/logic/trigger/push/teleport', () => {
+ const r = parseMapFile(mapWithIo())
+ expect(r.ok, r.ok ? '' : r.issues.join(', ')).toBe(true)
+ if (!r.ok) return
+ expect(validateMapFile(r.map)).toEqual([])
+ const override = compileMapFileV2(r.map)
+ expect(override.points).toEqual([
+ { id: 'dest', pos: [0, 1, 0], yaw: 1.5, class: 'teleport_destination' },
+ ])
+ expect(override.logic).toHaveLength(2)
+ expect(override.zones?.[0]?.trigger?.outputs).toHaveLength(1)
+ expect(override.zones?.[1]?.push?.speed).toBe(800)
+ })
+
+ it('survives a save/reload round trip', () => {
+ const r = parseMapFile(mapWithIo())
+ expect(r.ok).toBe(true)
+ if (!r.ok) return
+ const again = parseMapFile(JSON.parse(canonicalizeMapFile(r.map)))
+ expect(again.ok).toBe(true)
+ if (again.ok) expect(again.map).toEqual(r.map)
+ })
+
+ it('rejects a missing output target and a missing teleport destination', () => {
+ const m = mapWithIo()
+ m.zones[0]!.trigger!.outputs[0]!.target = 'ghost'
+ const missingTarget = validateMapFile(m).join(' ')
+ expect(missingTarget).toContain('missing entity "ghost"')
+
+ const m2 = mapWithIo()
+ m2.zones[2]!.teleport!.to = 'nope'
+ expect(validateMapFile(m2).join(' ')).toContain('is not a point')
+ })
+
+ it('rejects an unknown input through the zod enum', () => {
+ const m = mapWithIo()
+ m.zones[0]!.trigger!.outputs[0]!.input = 'DoSomething' as never
+ expect(parseMapFile(m).ok).toBe(false)
+ })
+})
+
describe('map-authored NPCs', () => {
describe('map-authored NPCs', () => {
it('an older map with no npcs reads as none, and a new one starts with none', () => {
it('an older map with no npcs reads as none, and a new one starts with none', () => {
const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>
const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>
@@ -15,13 +15,17 @@ import { z } from 'zod'
@@ -15,13 +15,17 @@ import { z } from 'zod'
import {
import {
FaceStyleSchema,
FaceStyleSchema,
HexColorSchema,
HexColorSchema,
+ LogicDefSchema,
MapLightSchema,
MapLightSchema,
+ PointDefSchema,
StaticBodySchema,
StaticBodySchema,
ZoneDefSchema,
ZoneDefSchema,
MovementStyleSchema,
MovementStyleSchema,
SurfMapMetaSchema,
SurfMapMetaSchema,
type FaceStyle,
type FaceStyle,
+ type LogicDef,
type MovementStyle,
type MovementStyle,
+ type PointDef,
type SurfMapMeta,
type SurfMapMeta,
type ZoneDef,
type ZoneDef,
} from './schema/world.js'
} from './schema/world.js'
@@ -171,6 +175,10 @@ export const MapFileV2Schema = z.object({
@@ -171,6 +175,10 @@ export const MapFileV2Schema = z.object({
lights: z.array(MapLightSchemaV2).max(256).default([]),
lights: z.array(MapLightSchemaV2).max(256).default([]),
/** Map-authored zones; they augment the base world's content zones. */
/** Map-authored zones; they augment the base world's content zones. */
zones: z.array(MapZoneSchemaV2).max(512).default([]),
zones: z.array(MapZoneSchemaV2).max(512).default([]),
+ /** Named point entities (`info_target`, `info_teleport_destination`, cameras). */
+ points: z.array(PointDefSchema).max(4096).default([]),
+ /** Logic entities: relay, auto and timer. */
+ logic: z.array(LogicDefSchema).max(4096).default([]),
models: z
models: z
.array(z.object({ id: z.string(), name: z.string(), glb: z.string(), bounds: vec3 }))
.array(z.object({ id: z.string(), name: z.string(), glb: z.string(), bounds: vec3 }))
.max(512)
.max(512)
@@ -199,6 +207,8 @@ export type TerrainObjectV2 = z.infer<typeof TerrainObjectSchemaV2>
@@ -199,6 +207,8 @@ export type TerrainObjectV2 = z.infer<typeof TerrainObjectSchemaV2>
export type StaticObjectV2 = z.infer<typeof StaticObjectSchemaV2>
export type StaticObjectV2 = z.infer<typeof StaticObjectSchemaV2>
export type MapLightV2 = z.infer<typeof MapLightSchemaV2>
export type MapLightV2 = z.infer<typeof MapLightSchemaV2>
export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>
export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>
+export type MapPointV2 = PointDef
+export type MapLogicV2 = LogicDef
export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapPropV2 = MapFileV2['props'][number]
export type MapPropV2 = MapFileV2['props'][number]
export type MapNpcV2 = MapFileV2['npcs'][number]
export type MapNpcV2 = MapFileV2['npcs'][number]
@@ -220,6 +230,8 @@ export function emptyMapV2(): MapFileV2 {
@@ -220,6 +230,8 @@ export function emptyMapV2(): MapFileV2 {
npcs: [],
npcs: [],
lights: [],
lights: [],
zones: [],
zones: [],
+ points: [],
+ logic: [],
models: [],
models: [],
textures: [],
textures: [],
}
}
@@ -233,6 +245,8 @@ const ID_KINDS = [
@@ -233,6 +245,8 @@ const ID_KINDS = [
['props', 'pr'],
['props', 'pr'],
['lights', 'light'],
['lights', 'light'],
['zones', 'zone'],
['zones', 'zone'],
+ ['points', 'point'],
+ ['logic', 'logic'],
['npcs', 'npc'],
['npcs', 'npc'],
] as const
] as const
@@ -365,36 +379,97 @@ export function validateMapFile(map: MapFileV2): string[] {
@@ -365,36 +379,97 @@ export function validateMapFile(map: MapFileV2): string[] {
for (const n of map.npcs) claim(n.id, 'npc')
for (const n of map.npcs) claim(n.id, 'npc')
for (const l of map.lights) claim(l.id, 'light')
for (const l of map.lights) claim(l.id, 'light')
for (const z0 of map.zones) claim(z0.id, 'zone')
for (const z0 of map.zones) claim(z0.id, 'zone')
+ for (const p of map.points) claim(p.id, 'point')
+ for (const l of map.logic) claim(l.id, 'logic')
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
+ issues.push(...ioIssues(map))
if (map.surf) issues.push(...surfIssues(map))
if (map.surf) issues.push(...surfIssues(map))
return issues
return issues
}
}
+/**
+ * Cross-field I/O checks the schema cannot express: every output target must
+ * resolve to an entity id, and a teleport destination must be a point.
+ * Inputs are already type-checked by `IoInputSchema`; here we check the edges.
+ */
+function ioIssues(map: MapFileV2): string[] {
+ const issues: string[] = []
+ const points = new Set(map.points.map((p) => p.id))
+ const targets = new Set<string>([
+ ...map.points.map((p) => p.id),
+ ...map.logic.map((l) => l.id),
+ ...map.zones.map((z) => z.id),
+ ...map.statics.map((s) => s.brushEntity?.targetname ?? s.id),
+ ])
+
+ const checkOutputs = (owner: string, outputs: readonly { target: string }[]): void => {
+ for (const o of outputs)
+ if (!targets.has(o.target)) issues.push(`${owner}: output targets missing entity "${o.target}"`)
+ }
+
+ for (const z of map.zones) {
+ if (z.teleport && !points.has(z.teleport.to))
+ issues.push(`zone "${z.id}": teleport destination "${z.teleport.to}" is not a point`)
+ if (z.push) {
+ const [x, y, zz] = z.push.dir
+ if (Math.hypot(x, y, zz) < 1e-9)
+ issues.push(`zone "${z.id}": push dir must not be the zero vector`)
+ }
+ if (z.trigger) checkOutputs(`zone "${z.id}"`, z.trigger.outputs)
+ }
+ for (const l of map.logic) checkOutputs(`logic "${l.id}"`, l.outputs)
+ for (const s of map.statics)
+ if (s.brushEntity) checkOutputs(`static "${s.id}"`, s.brushEntity.outputs)
+ return issues
+}
+
+/** `surf.kind` plus the Source timer zone-name aliases. */
+function surfMetaFor(
+ zone: MapZoneV2,
+ stages: number,
+): { kind: 'start' | 'checkpoint' | 'finish' | 'kill'; stage: number; spawn?: [number, number, number] } | null {
+ if (zone.surf) {
+ return {
+ kind: zone.surf.kind,
+ stage: zone.surf.stage,
+ ...(zone.surf.spawn ? { spawn: zone.surf.spawn } : {}),
+ }
+ }
+ const id = zone.id
+ if (id === 'map_start' || zone.name === 'map_start') return { kind: 'start', stage: 0 }
+ if (id === 'map_end' || zone.name === 'map_end') return { kind: 'finish', stage: stages }
+ if (id === 'map_kill' || zone.name === 'map_kill') return { kind: 'kill', stage: 0 }
+ const cp = /^map_cp(\d+)$/.exec(id) ?? /^map_cp(\d+)$/.exec(zone.name)
+ if (cp) return { kind: 'checkpoint', stage: Number(cp[1]) }
+ return null
+}
+
/** A surf map's zones must describe exactly one ordered run. */
/** A surf map's zones must describe exactly one ordered run. */
function surfIssues(map: MapFileV2): string[] {
function surfIssues(map: MapFileV2): string[] {
const issues: string[] = []
const issues: string[] = []
const surf = map.surf
const surf = map.surf
if (!surf) return issues
if (!surf) return issues
- const zones = map.zones.filter((z) => z.surf)
- const starts = zones.filter((z) => z.surf!.kind === 'start')
- const finishes = zones.filter((z) => z.surf!.kind === 'finish')
+ const zones = map.zones
+ .map((z) => ({ zone: z, meta: surfMetaFor(z, surf.stages) }))
+ .filter((x) => x.meta !== null)
+ const starts = zones.filter((z) => z.meta!.kind === 'start')
+ const finishes = zones.filter((z) => z.meta!.kind === 'finish')
if (starts.length !== 1) issues.push(`surf: expected one start zone, found ${starts.length}`)
if (starts.length !== 1) issues.push(`surf: expected one start zone, found ${starts.length}`)
if (finishes.length !== 1) issues.push(`surf: expected one finish zone, found ${finishes.length}`)
if (finishes.length !== 1) issues.push(`surf: expected one finish zone, found ${finishes.length}`)
if (map.movement !== 'surf') issues.push('surf: a surf map must set movement to "surf"')
if (map.movement !== 'surf') issues.push('surf: a surf map must set movement to "surf"')
- for (const zone of zones) {
- const meta = zone.surf!
- const maxStage = meta.kind === 'finish' ? surf.stages : surf.stages - 1
- if (meta.stage > maxStage)
- issues.push(`surf zone "${zone.id}": stage ${meta.stage} exceeds ${maxStage}`)
- if (meta.kind === 'checkpoint' && !meta.spawn)
+ for (const { zone, meta } of zones) {
+ const maxStage = meta!.kind === 'finish' ? surf.stages : surf.stages - 1
+ if (meta!.stage > maxStage)
+ issues.push(`surf zone "${zone.id}": stage ${meta!.stage} exceeds ${maxStage}`)
+ if (meta!.kind === 'checkpoint' && !meta!.spawn)
issues.push(`surf zone "${zone.id}": a checkpoint needs a respawn point`)
issues.push(`surf zone "${zone.id}": a checkpoint needs a respawn point`)
}
}
- const checkpoints = zones.filter((z) => z.surf!.kind === 'checkpoint')
+ const checkpoints = zones.filter((z) => z.meta!.kind === 'checkpoint')
if (checkpoints.length !== surf.stages - 1)
if (checkpoints.length !== surf.stages - 1)
issues.push(
issues.push(
`surf: ${checkpoints.length} checkpoints for ${surf.stages} stages (expected ${surf.stages - 1})`,
`surf: ${checkpoints.length} checkpoints for ${surf.stages} stages (expected ${surf.stages - 1})`,
)
)
- const stages = checkpoints.map((z) => z.surf!.stage).sort((a, b) => a - b)
+ const stages = checkpoints.map((z) => z.meta!.stage).sort((a, b) => a - b)
for (let i = 0; i < stages.length; i++)
for (let i = 0; i < stages.length; i++)
if (stages[i] !== i + 1) {
if (stages[i] !== i + 1) {
issues.push(`surf: checkpoint stages must be 1..${surf.stages - 1}, got ${stages.join(',')}`)
issues.push(`surf: checkpoint stages must be 1..${surf.stages - 1}, got ${stages.join(',')}`)
@@ -471,6 +546,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
@@ -471,6 +546,8 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
})),
})),
statics: map.statics.map((b) => ({ ...b })),
statics: map.statics.map((b) => ({ ...b })),
zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),
zones: map.zones.map((z0): ZoneDef => ({ ...z0, min: [...z0.min], max: [...z0.max] })),
+ points: map.points.map((p) => ({ ...p, pos: [...p.pos] })),
+ logic: map.logic.map((l) => ({ ...l })),
...(map.movement ? { movement: map.movement } : {}),
...(map.movement ? { movement: map.movement } : {}),
...(map.surf ? { surf: map.surf } : {}),
...(map.surf ? { surf: map.surf } : {}),
...(map.spawn ? { spawn: map.spawn } : {}),
...(map.spawn ? { spawn: map.spawn } : {}),
@@ -91,6 +91,104 @@ export const FaceStyleSchema = z.object({
@@ -91,6 +91,104 @@ export const FaceStyleSchema = z.object({
})
})
export type FaceStyle = z.infer<typeof FaceStyleSchema>
export type FaceStyle = z.infer<typeof FaceStyleSchema>
+/**
+ * Inputs an entity accepts through the deterministic I/O graph. Source has a
+ * much larger vocabulary; this is the subset the surf trigger core needs plus
+ * the door/button inputs the next tier will drive. An unknown input is a
+ * parse error, so a typo in the editor cannot silently do nothing.
+ */
+export const IoInputSchema = z.enum([
+ 'Enable',
+ 'Disable',
+ 'Toggle',
+ 'Trigger',
+ 'Open',
+ 'Close',
+ 'Press',
+ 'Teleport',
+ 'SetSpeed',
+ 'ResetTimer',
+ 'FireTimer',
+ 'CancelPending',
+ 'TouchTest',
+])
+export type IoInput = z.infer<typeof IoInputSchema>
+
+/**
+ * One edge in the event graph: "when `event` happens, send `input` to
+ * `target` after `delay` seconds". `once` means the edge fires at most once
+ * for the lifetime of the map.
+ */
+export const OutputSchema = z.object({
+ event: z.string().min(1).max(64),
+ target: z.string().min(1).max(64),
+ input: IoInputSchema,
+ param: z.string().optional(),
+ delay: z.number().finite().nonnegative().optional(),
+ once: z.boolean().optional(),
+})
+export type Output = z.infer<typeof OutputSchema>
+
+/** `trigger_multiple` / `trigger_once`: a volume that fires touch outputs. */
+export const TriggerZoneSchema = z.object({
+ wait: z.number().finite().nonnegative().optional(),
+ once: z.boolean().optional(),
+ outputs: z.array(OutputSchema).default([]),
+})
+export type TriggerZone = z.infer<typeof TriggerZoneSchema>
+
+/** `trigger_push`: a volume that applies a velocity to players inside it. */
+export const PushZoneSchema = z.object({
+ dir: finiteVec3,
+ /** Magnitude in Source units/s; the engine converts to m/s at runtime. */
+ speed: z.number().finite().min(0),
+ onlyFalling: z.boolean().optional(),
+})
+export type PushZone = z.infer<typeof PushZoneSchema>
+
+/** `trigger_teleport`: a volume that moves a player to a destination point. */
+export const TeleportZoneSchema = z.object({
+ to: z.string().min(1).max(64),
+ landmark: z.string().min(1).max(64).optional(),
+ keepAngles: z.boolean().optional(),
+})
+export type TeleportZone = z.infer<typeof TeleportZoneSchema>
+
+/**
+ * A point entity (`info_target`, `info_teleport_destination`, camera marker):
+ * a named position and optional facing, referenced by trigger outputs.
+ */
+export const PointDefSchema = z.object({
+ id: z.string().regex(/^[a-z0-9_-]+$/),
+ pos: finiteVec3,
+ yaw: z.number().optional(),
+ class: z.enum(['target', 'teleport_destination', 'camera']).default('target'),
+})
+export type PointDef = z.infer<typeof PointDefSchema>
+
+/**
+ * A logic entity: `logic_relay` forwards inputs, `logic_auto` fires on map
+ * spawn, and `logic_timer` fires on an interval.
+ */
+export const LogicDefSchema = z.object({
+ id: z.string().regex(/^[a-z0-9_-]+$/),
+ class: z.enum(['relay', 'auto', 'timer']),
+ wait: z.number().finite().nonnegative().optional(),
+ outputs: z.array(OutputSchema).default([]),
+})
+export type LogicDef = z.infer<typeof LogicDefSchema>
+
+/** A brush entity (`func_door`, `func_button`, `func_brush`) attached to a static. */
+export const BrushEntitySchema = z.object({
+ class: z.enum(['door', 'button', 'brush']),
+ targetname: z.string().min(1).max(64).optional(),
+ speed: z.number().finite().nonnegative().optional(),
+ lip: z.number().finite().nonnegative().optional(),
+ wait: z.number().finite().nonnegative().optional(),
+ outputs: z.array(OutputSchema).default([]),
+})
+export type BrushEntity = z.infer<typeof BrushEntitySchema>
+
export const StaticBodySchema = z.object({
export const StaticBodySchema = z.object({
/** Stable editor/document id (selection, collab locks). */
/** Stable editor/document id (selection, collab locks). */
id: z.string().optional(),
id: z.string().optional(),
@@ -119,6 +217,8 @@ export const StaticBodySchema = z.object({
@@ -119,6 +217,8 @@ export const StaticBodySchema = z.object({
uv: FaceStyleSchema.optional(),
uv: FaceStyleSchema.optional(),
/** Per-face overrides for box statics (face index '0'..'5'). */
/** Per-face overrides for box statics (face index '0'..'5'). */
faces: z.record(z.string(), FaceStyleSchema).optional(),
faces: z.record(z.string(), FaceStyleSchema).optional(),
+ /** Optional brush-entity behaviour (`func_door`/`func_button`/`func_brush`). */
+ brushEntity: BrushEntitySchema.optional(),
})
})
export const ResourceNodeSpawnSchema = z.object({
export const ResourceNodeSpawnSchema = z.object({
@@ -175,6 +275,12 @@ export const ZoneDefSchema = z.object({
@@ -175,6 +275,12 @@ export const ZoneDefSchema = z.object({
}),
}),
/** Surf trigger metadata; absent on ordinary rule zones. */
/** Surf trigger metadata; absent on ordinary rule zones. */
surf: SurfZoneMetaSchema.optional(),
surf: SurfZoneMetaSchema.optional(),
+ /** `trigger_multiple` / `trigger_once` touch outputs. */
+ trigger: TriggerZoneSchema.optional(),
+ /** `trigger_push` velocity volume. */
+ push: PushZoneSchema.optional(),
+ /** `trigger_teleport` destination volume. */
+ teleport: TeleportZoneSchema.optional(),
})
})
export const WorldDefSchema = z.object({
export const WorldDefSchema = z.object({
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
import type { SurfaceMaterialData } from './surface.js'
import type { SurfaceMaterialData } from './surface.js'
-import type { FaceStyle, StaticBody, WorldDef, ZoneDef } from './schema/world.js'
+import type { FaceStyle, LogicDef, PointDef, StaticBody, WorldDef, ZoneDef } from './schema/world.js'
/**
/**
* Deterministic terrain heightfield shared by server physics, client
* Deterministic terrain heightfield shared by server physics, client
@@ -189,6 +189,10 @@ export interface MapOverride {
@@ -189,6 +189,10 @@ export interface MapOverride {
* The base list is never mutated — see `ZoneIndex.setMapZones`.
* The base list is never mutated — see `ZoneIndex.setMapZones`.
*/
*/
zones?: ZoneDef[]
zones?: ZoneDef[]
+ /** Named point entities (`info_target`, `info_teleport_destination`, cameras). */
+ points?: PointDef[]
+ /** Logic entities: relay, auto and timer. */
+ logic?: LogicDef[]
/** Editor-placed spawn point (overrides the world def). */
/** Editor-placed spawn point (overrides the world def). */
spawn?: [number, number, number]
spawn?: [number, number, number]
spawnYaw?: number
spawnYaw?: number
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+/**
+ * The deterministic I/O queue. One event edge is an output: when `event`
+ * fires, send `input` to `target` after `delay` seconds. Seconds become
+ * whole ticks with `Math.round`, so the same map and the same tick rate
+ * always produce the same ordering — server and prediction cannot disagree
+ * about when something fires.
+ */
+
+export interface OutputSpec {
+ event: string
+ target: string
+ input: string
+ param?: string | undefined
+ delay?: number | undefined
+ once?: boolean | undefined
+}
+
+export interface QueuedOutput {
+ dueTick: number
+ /** Monotonic insertion order, the tie-breaker when two edges share a tick. */
+ seq: number
+ event: string
+ target: string
+ input: string
+ param?: string
+ once?: boolean
+}
+
+export function delayTicks(delaySeconds: number | undefined, tickRate: number): number {
+ if (delaySeconds === undefined || delaySeconds <= 0) return 0
+ return Math.round(delaySeconds * tickRate)
+}
+
+/** A stable identity for an output, used to honour `once`. */
+export function outputKey(output: OutputSpec): string {
+ return `${output.event}\u0000${output.target}\u0000${output.input}\u0000${output.param ?? ''}`
+}
+
+export class OutputQueue {
+ private items: QueuedOutput[] = []
+ private nextSeq = 0
+
+ get length(): number {
+ return this.items.length
+ }
+
+ schedule(outputs: readonly OutputSpec[], nowTick: number, tickRate: number): void {
+ for (const output of outputs) {
+ this.items.push({
+ dueTick: nowTick + delayTicks(output.delay, tickRate),
+ seq: this.nextSeq++,
+ event: output.event,
+ target: output.target,
+ input: output.input,
+ ...(output.param !== undefined ? { param: output.param } : {}),
+ ...(output.once !== undefined ? { once: output.once } : {}),
+ })
+ }
+ this.items.sort((a, b) => a.dueTick - b.dueTick || a.seq - b.seq)
+ }
+
+ /** Pop every edge due at or before `nowTick`, in (dueTick, seq) order. */
+ due(nowTick: number): QueuedOutput[] {
+ const out: QueuedOutput[] = []
+ while (this.items.length > 0 && this.items[0]!.dueTick <= nowTick) {
+ out.push(this.items.shift()!)
+ }
+ return out
+ }
+
+ clear(): void {
+ this.items = []
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+import type { LogicDef } from '@openvibe/content'
+import { delayTicks, type OutputSpec } from './io.js'
+
+/**
+ * Runtime logic entities: relay forwards inputs, auto fires on map spawn,
+ * and timer fires on an interval. All are pure data plus a small state
+ * machine — no wall clock, only ticks.
+ */
+
+export type LogicClass = 'relay' | 'auto' | 'timer'
+
+export interface LogicEntity {
+ id: string
+ class: LogicClass
+ enabled: boolean
+ wait: number
+ outputs: OutputSpec[]
+ /** Timer only: the tick at which the next OnTimer is due. */
+ nextFireTick: number | null
+}
+
+export function buildLogicEntity(def: LogicDef): LogicEntity {
+ return {
+ id: def.id,
+ class: def.class,
+ enabled: def.class !== 'auto',
+ wait: def.wait ?? 0,
+ outputs: def.outputs.map((o) => ({ ...o })),
+ nextFireTick: null,
+ }
+}
+
+export function timerIntervalTicks(entity: LogicEntity, tickRate: number): number {
+ return Math.max(1, delayTicks(entity.wait, tickRate))
+}
+
+/**
+ * Fire an input on a logic entity. Returns the outputs it wants scheduled.
+ * Timer scheduling lives here so `Enable`/`ResetTimer` and the graph's tick
+ * cannot disagree about the next due tick.
+ */
+export function fireLogicInput(
+ entity: LogicEntity,
+ input: string,
+ nowTick: number,
+ tickRate: number,
+): OutputSpec[] {
+ switch (entity.class) {
+ case 'relay': {
+ if (input === 'Trigger') {
+ if (!entity.enabled) return []
+ return entity.outputs
+ }
+ if (input === 'Enable') {
+ entity.enabled = true
+ return []
+ }
+ if (input === 'Disable') {
+ entity.enabled = false
+ return []
+ }
+ if (input === 'Toggle') {
+ entity.enabled = !entity.enabled
+ return []
+ }
+ return []
+ }
+ case 'auto': {
+ // Auto has no runtime inputs; its OnMapSpawn is seeded by the graph.
+ return []
+ }
+ case 'timer': {
+ if (input === 'Enable') {
+ entity.enabled = true
+ entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)
+ return []
+ }
+ if (input === 'Disable') {
+ entity.enabled = false
+ entity.nextFireTick = null
+ return []
+ }
+ if (input === 'Toggle') {
+ entity.enabled = !entity.enabled
+ entity.nextFireTick = entity.enabled
+ ? nowTick + timerIntervalTicks(entity, tickRate)
+ : null
+ return []
+ }
+ if (input === 'ResetTimer') {
+ if (entity.enabled) entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)
+ return []
+ }
+ if (input === 'FireTimer') {
+ const outputs = entity.outputs
+ if (entity.enabled) entity.nextFireTick = nowTick + timerIntervalTicks(entity, tickRate)
+ return outputs
+ }
+ return []
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest'
+import type { ZoneDef } from '@openvibe/content'
+import { delayTicks, OutputQueue } from './io.js'
+import {
+ buildTriggerZone,
+ classifyZone,
+ touchTransition,
+ TriggerGraph,
+} from './trigger.js'
+
+const zone = (over: Record<string, unknown> = {}): ZoneDef =>
+ ({
+ id: 't1',
+ name: 'Trigger',
+ min: [-1, 0, -1],
+ max: [1, 2, 1],
+ rules: { pvp: true, build: true, physgun: true },
+ ...over,
+ }) as unknown as ZoneDef
+
+const p = (x = 0, y = 1, z = 0): { x: number; y: number; z: number } => ({ x, y, z })
+
+describe('OutputQueue', () => {
+ it('orders by due tick, then insertion sequence', () => {
+ const q = new OutputQueue()
+ q.schedule([{ event: 'a', target: 'x', input: 'Trigger', delay: 2 }], 0, 10)
+ q.schedule([{ event: 'b', target: 'x', input: 'Trigger', delay: 1 }], 0, 10)
+ q.schedule([{ event: 'c', target: 'x', input: 'Trigger', delay: 1 }], 0, 10)
+ expect(delayTicks(2, 10)).toBe(20)
+ expect(delayTicks(1, 10)).toBe(10)
+ expect(q.due(9)).toEqual([])
+ expect(q.due(10).map((e) => e.event)).toEqual(['b', 'c'])
+ expect(q.due(20).map((e) => e.event)).toEqual(['a'])
+ })
+})
+
+describe('classifyZone / buildTriggerZone', () => {
+ it('recognises teleport, push, once and multiple volumes', () => {
+ expect(classifyZone(zone({ teleport: { to: 'dest' } }))).toBe('trigger_teleport')
+ expect(classifyZone(zone({ push: { dir: [0, 1, 0], speed: 100 } }))).toBe('trigger_push')
+ expect(classifyZone(zone({ trigger: { once: true, outputs: [] } }))).toBe('trigger_once')
+ expect(classifyZone(zone({ trigger: { outputs: [] } }))).toBe('trigger_multiple')
+ expect(classifyZone(zone())).toBeNull()
+ })
+
+ it('defaults trigger_multiple wait to the Source-like 0.2s', () => {
+ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!
+ expect(trigger.trigger!.wait).toBe(0.2)
+ })
+})
+
+describe('touchTransition', () => {
+ it('fires start and end once per crossing, and reports through-crossing starts', () => {
+ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!
+ const start = touchTransition(trigger, p(0, 1, -2), p(0, 1, 0), false)
+ expect(start.kind).toBe('start')
+ expect(start.nowInside).toBe(true)
+
+ const stay = touchTransition(trigger, p(0, 1, 0), p(0.5, 1, 0), true)
+ expect(stay.kind).toBe('none')
+ expect(stay.nowInside).toBe(true)
+
+ const end = touchTransition(trigger, p(0, 1, 0), p(0, 1, 2), true)
+ expect(end.kind).toBe('end')
+ expect(end.nowInside).toBe(false)
+ })
+
+ it('reports a start for a catch-up segment that passes all the way through', () => {
+ const trigger = buildTriggerZone(zone({ trigger: { outputs: [] } }))!
+ const through = touchTransition(trigger, p(0, 1, -2), p(0, 1, 2), false)
+ expect(through.kind).toBe('start')
+ })
+})
+
+describe('TriggerGraph', () => {
+ it('fires logic_auto OnMapSpawn edges on the first tick', () => {
+ const t = zone({ id: 'vol', trigger: { outputs: [] } })
+ const graph = new TriggerGraph({
+ zones: [t],
+ points: [],
+ logic: [
+ { id: 'auto1', class: 'auto', outputs: [{ event: 'OnMapSpawn', target: 'vol', input: 'Disable' }] },
+ ],
+ tickRate: 100,
+ })
+ expect(graph.triggers[0]!.enabled).toBe(true)
+ graph.tick(0)
+ expect(graph.triggers[0]!.enabled).toBe(false)
+ })
+
+ it('makes logic_relay delays tick-deterministic', () => {
+ const t = zone({ id: 'vol', trigger: { outputs: [] } })
+ const graph = new TriggerGraph({
+ zones: [t],
+ points: [],
+ logic: [
+ {
+ id: 'relay',
+ class: 'relay',
+ outputs: [{ event: 'OnTrigger', target: 'vol', input: 'Disable', delay: 0.2 }],
+ },
+ ],
+ tickRate: 100,
+ })
+ graph.fireInput('relay', 'Trigger', undefined, 0)
+ graph.drain(19)
+ expect(graph.triggers[0]!.enabled).toBe(true)
+ graph.drain(20)
+ expect(graph.triggers[0]!.enabled).toBe(false)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -0,0 +1,263 @@
+import type { LogicDef, PointDef, ZoneDef } from '@openvibe/content'
+import { insideBox, segmentAabbEntry, segmentAabbExit } from '../surf/run.js'
+import { OutputQueue, outputKey, type OutputSpec } from './io.js'
+import {
+ buildLogicEntity,
+ fireLogicInput,
+ timerIntervalTicks,
+ type LogicEntity,
+} from './logic.js'
+
+/**
+ * Runtime trigger entities. These are engine-free: the server feeds them
+ * authoritative movement segments, and the same shape can be consumed by a
+ * 2D/top-down presentation without a physics body anywhere in the model.
+ */
+
+export type TriggerKind =
+ | 'trigger_multiple'
+ | 'trigger_once'
+ | 'trigger_teleport'
+ | 'trigger_push'
+
+export interface Vec3 {
+ x: number
+ y: number
+ z: number
+}
+
+export interface Aabb {
+ min: readonly [number, number, number]
+ max: readonly [number, number, number]
+}
+
+export interface TriggerZoneRuntime {
+ id: string
+ kind: TriggerKind
+ min: [number, number, number]
+ max: [number, number, number]
+ trigger?: { wait: number; once: boolean; outputs: OutputSpec[] }
+ push?: { dir: [number, number, number]; speed: number; onlyFalling: boolean }
+ teleport?: { to: string; landmark?: string; keepAngles: boolean }
+ enabled: boolean
+}
+
+export interface PointEntity {
+ id: string
+ pos: Vec3
+ yaw: number
+ class: 'target' | 'teleport_destination' | 'camera'
+}
+
+export const DEFAULT_MULTIPLE_WAIT = 0.2
+
+export function classifyZone(zone: ZoneDef): TriggerKind | null {
+ if (zone.teleport) return 'trigger_teleport'
+ if (zone.push) return 'trigger_push'
+ if (zone.trigger) return zone.trigger.once ? 'trigger_once' : 'trigger_multiple'
+ return null
+}
+
+export function buildTriggerZone(zone: ZoneDef): TriggerZoneRuntime | null {
+ const kind = classifyZone(zone)
+ if (!kind) return null
+ return {
+ id: zone.id,
+ kind,
+ min: [zone.min[0], zone.min[1], zone.min[2]],
+ max: [zone.max[0], zone.max[1], zone.max[2]],
+ ...(zone.trigger
+ ? {
+ trigger: {
+ wait: zone.trigger.wait ?? (zone.trigger.once ? 0 : DEFAULT_MULTIPLE_WAIT),
+ once: zone.trigger.once ?? false,
+ outputs: zone.trigger.outputs.map((o) => ({ ...o })),
+ },
+ }
+ : {}),
+ ...(zone.push
+ ? {
+ push: {
+ dir: [zone.push.dir[0], zone.push.dir[1], zone.push.dir[2]],
+ speed: zone.push.speed,
+ onlyFalling: zone.push.onlyFalling ?? false,
+ },
+ }
+ : {}),
+ ...(zone.teleport
+ ? {
+ teleport: {
+ to: zone.teleport.to,
+ ...(zone.teleport.landmark !== undefined
+ ? { landmark: zone.teleport.landmark }
+ : {}),
+ keepAngles: zone.teleport.keepAngles ?? false,
+ },
+ }
+ : {}),
+ enabled: true,
+ }
+}
+
+export function buildPointEntity(point: PointDef): PointEntity {
+ return {
+ id: point.id,
+ pos: { x: point.pos[0], y: point.pos[1], z: point.pos[2] },
+ yaw: point.yaw ?? 0,
+ class: point.class,
+ }
+}
+
+/** Outputs whose `event` matches a touch/trigger event name. */
+export function outputsFor(trigger: TriggerZoneRuntime, event: string): OutputSpec[] {
+ return (trigger.trigger?.outputs ?? []).filter((o) => o.event === event)
+}
+
+export type TouchTransition =
+ | { kind: 'start'; frac: number; nowInside: boolean }
+ | { kind: 'end'; frac: number; nowInside: boolean }
+ | { kind: 'none'; nowInside: boolean }
+
+/**
+ * What one movement segment did to one trigger volume. Entry uses the slab
+ * test, so a segment that starts outside and ends outside still reports a
+ * start if it passed through the box — the catch-up guarantee.
+ */
+export function touchTransition(
+ trigger: TriggerZoneRuntime,
+ from: Vec3,
+ to: Vec3,
+ wasInside: boolean,
+): TouchTransition {
+ const box: Aabb = { min: trigger.min, max: trigger.max }
+ if (wasInside) {
+ if (!insideBox(box, to)) {
+ const frac = segmentAabbExit(from, to, box) ?? 1
+ return { kind: 'end', frac, nowInside: false }
+ }
+ return { kind: 'none', nowInside: true }
+ }
+ const entry = segmentAabbEntry(from, to, box)
+ if (entry !== null) return { kind: 'start', frac: entry, nowInside: insideBox(box, to) }
+ return { kind: 'none', nowInside: insideBox(box, to) }
+}
+
+export interface TriggerGraphInput {
+ zones: readonly ZoneDef[]
+ points: readonly PointDef[]
+ logic: readonly LogicDef[]
+ tickRate: number
+}
+
+const MAX_DRAIN_ITERATIONS = 10_000
+
+/**
+ * The whole map's trigger graph: trigger volumes, point entities, logic
+ * entities and the one deterministic queue that connects them.
+ *
+ * `drain` fires every edge due at the current tick, including delay-0 edges
+ * scheduled by an earlier edge in the same tick, so a relay chain resolves
+ * immediately and deterministically.
+ */
+export class TriggerGraph {
+ readonly triggers: TriggerZoneRuntime[]
+ readonly points = new Map<string, PointEntity>()
+ private readonly logic = new Map<string, LogicEntity>()
+ private readonly queue = new OutputQueue()
+ private readonly tickRate: number
+ private readonly firedOnce = new Set<string>()
+ private started = false
+
+ constructor(input: TriggerGraphInput) {
+ this.tickRate = input.tickRate
+ this.triggers = input.zones
+ .map(buildTriggerZone)
+ .filter((t): t is TriggerZoneRuntime => t !== null)
+ for (const point of input.points) {
+ const p = buildPointEntity(point)
+ this.points.set(p.id, p)
+ }
+ for (const def of input.logic) {
+ const l = buildLogicEntity(def)
+ this.logic.set(l.id, l)
+ }
+ }
+
+ /** Seed `logic_auto` OnMapSpawn edges and initial timers, once. */
+ begin(nowTick: number): void {
+ if (this.started) return
+ this.started = true
+ for (const l of this.logic.values()) {
+ if (l.class !== 'auto') continue
+ this.schedule(l.outputs, nowTick)
+ }
+ }
+
+ /** Fire every edge due at `nowTick`, following delay-0 chains. */
+ drain(nowTick: number): void {
+ for (let i = 0; i < MAX_DRAIN_ITERATIONS; i++) {
+ const due = this.queue.due(nowTick)
+ if (due.length === 0) return
+ for (const edge of due) this.dispatch(edge.target, edge.input, edge.param, nowTick)
+ }
+ }
+
+ /** One tick: seed auto, fire due timers, then drain. */
+ tick(nowTick: number): void {
+ this.begin(nowTick)
+ for (const l of this.logic.values()) {
+ if (l.class !== 'timer' || !l.enabled) continue
+ if (l.nextFireTick === null || l.nextFireTick > nowTick) continue
+ this.schedule(l.outputs, nowTick)
+ l.nextFireTick = nowTick + timerIntervalTicks(l, this.tickRate)
+ }
+ this.drain(nowTick)
+ }
+
+ /** Schedule outputs from a touch event (does not drain; call drain after). */
+ fireTouchOutputs(trigger: TriggerZoneRuntime, event: string, nowTick: number): void {
+ this.schedule(outputsFor(trigger, event), nowTick)
+ }
+
+ /** Send an input to a named entity; schedules whatever it returns. */
+ fireInput(target: string, input: string, param: string | undefined, nowTick: number): void {
+ this.dispatch(target, input, param, nowTick)
+ }
+
+ /** Schedule edges, honouring `once` and dropping unknown targets. */
+ private schedule(outputs: readonly OutputSpec[], nowTick: number): void {
+ const pending: OutputSpec[] = []
+ for (const output of outputs) {
+ const key = outputKey(output)
+ if (output.once && this.firedOnce.has(key)) continue
+ if (output.once) this.firedOnce.add(key)
+ pending.push(output)
+ }
+ this.queue.schedule(pending, nowTick, this.tickRate)
+ }
+
+ private dispatch(target: string, input: string, param: string | undefined, nowTick: number): void {
+ const logic = this.logic.get(target)
+ if (logic) {
+ this.schedule(fireLogicInput(logic, input, nowTick, this.tickRate), nowTick)
+ return
+ }
+ const trigger = this.triggers.find((t) => t.id === target)
+ if (!trigger) return
+ if (input === 'Enable') {
+ trigger.enabled = true
+ return
+ }
+ if (input === 'Disable') {
+ trigger.enabled = false
+ return
+ }
+ if (input === 'Toggle') {
+ trigger.enabled = !trigger.enabled
+ return
+ }
+ if (input === 'Trigger') {
+ this.schedule(outputsFor(trigger, 'OnTrigger'), nowTick)
+ }
+ }
+}
@@ -9,6 +9,9 @@ export * from './movement/simulate.js'
@@ -9,6 +9,9 @@ export * from './movement/simulate.js'
export * from './movement/styles.js'
export * from './movement/styles.js'
export * from './surf/run.js'
export * from './surf/run.js'
export * from './surf/ghost.js'
export * from './surf/ghost.js'
+export * from './entities/io.js'
+export * from './entities/trigger.js'
+export * from './entities/logic.js'
export * from './skills/skills.js'
export * from './skills/skills.js'
export * from './zones/zones.js'
export * from './zones/zones.js'
export * from './entity/entity.js'
export * from './entity/entity.js'
@@ -151,6 +151,22 @@ export function segmentAabbExit(a: SurfVec3, b: SurfVec3, box: SurfBox): number
@@ -151,6 +151,22 @@ export function segmentAabbExit(a: SurfVec3, b: SurfVec3, box: SurfBox): number
return tExit >= 0 && tExit <= 1 ? tExit : null
return tExit >= 0 && tExit <= 1 ? tExit : null
}
}
+/**
+ * Source surf timer zone aliases. `surf.kind` stays the canonical form; a
+ * map authored with the interoperable trigger names (`map_start`, `map_cpN`,
+ * `map_end`, `map_kill`) still builds a run without duplicating the kind.
+ */
+export function surfAliasFor(
+ idOrName: string,
+): { kind: SurfZoneKind; stage?: number } | null {
+ if (idOrName === 'map_start') return { kind: 'start', stage: 0 }
+ if (idOrName === 'map_end') return { kind: 'finish' }
+ if (idOrName === 'map_kill') return { kind: 'kill', stage: 0 }
+ const cp = /^map_cp(\d+)$/.exec(idOrName)
+ if (cp) return { kind: 'checkpoint', stage: Number(cp[1]) }
+ return null
+}
+
/** Build the run description from a compiled map's surf metadata and zones. */
/** Build the run description from a compiled map's surf metadata and zones. */
export function surfRunMapOf(
export function surfRunMapOf(
meta: SurfMapMeta | undefined,
meta: SurfMapMeta | undefined,
@@ -159,25 +175,39 @@ export function surfRunMapOf(
@@ -159,25 +175,39 @@ export function surfRunMapOf(
spawnYaw = 0,
spawnYaw = 0,
id = meta?.title ?? 'surf',
id = meta?.title ?? 'surf',
): SurfRunMap {
): SurfRunMap {
- const tagged = zones.filter((z) => z.surf)
- const start = tagged.find((z) => z.surf!.kind === 'start')
- const finish = tagged.find((z) => z.surf!.kind === 'finish')
+ const tagged: { zone: ZoneDef; meta: NonNullable<ZoneDef['surf']> }[] = []
+ for (const zone of zones) {
+ if (zone.surf) {
+ tagged.push({ zone, meta: zone.surf })
+ continue
+ }
+ const alias = surfAliasFor(zone.id) ?? surfAliasFor(zone.name)
+ if (!alias) continue
+ const stage = alias.kind === 'finish'
+ ? (meta?.stages ?? 1)
+ : alias.kind === 'checkpoint'
+ ? (alias.stage ?? 0)
+ : 0
+ tagged.push({ zone, meta: { kind: alias.kind, stage } })
+ }
+ const start = tagged.find((t) => t.meta.kind === 'start')
+ const finish = tagged.find((t) => t.meta.kind === 'finish')
if (!start || !finish)
if (!start || !finish)
throw new Error('surf map needs a start zone and a finish zone')
throw new Error('surf map needs a start zone and a finish zone')
- const toZone = (z: ZoneDef): SurfRunZone => ({
- id: z.id,
- kind: z.surf!.kind,
- stage: z.surf!.stage,
- min: [z.min[0], z.min[1], z.min[2]],
- max: [z.max[0], z.max[1], z.max[2]],
- ...(z.surf!.spawn
- ? { spawn: { x: z.surf!.spawn[0], y: z.surf!.spawn[1], z: z.surf!.spawn[2] } }
+ const toZone = (t: (typeof tagged)[number]): SurfRunZone => ({
+ id: t.zone.id,
+ kind: t.meta.kind,
+ stage: t.meta.stage,
+ min: [t.zone.min[0], t.zone.min[1], t.zone.min[2]],
+ max: [t.zone.max[0], t.zone.max[1], t.zone.max[2]],
+ ...(t.meta.spawn
+ ? { spawn: { x: t.meta.spawn[0], y: t.meta.spawn[1], z: t.meta.spawn[2] } }
: {}),
: {}),
- ...(z.surf!.yaw !== undefined ? { yaw: z.surf!.yaw } : {}),
+ ...(t.meta.yaw !== undefined ? { yaw: t.meta.yaw } : {}),
})
})
const checkpoints = tagged
const checkpoints = tagged
- .filter((z) => z.surf!.kind === 'checkpoint')
- .sort((a, b) => a.surf!.stage - b.surf!.stage)
+ .filter((t) => t.meta.kind === 'checkpoint')
+ .sort((a, b) => a.meta.stage - b.meta.stage)
.map(toZone)
.map(toZone)
return {
return {
id,
id,
@@ -186,7 +216,7 @@ export function surfRunMapOf(
@@ -186,7 +216,7 @@ export function surfRunMapOf(
start: toZone(start),
start: toZone(start),
checkpoints,
checkpoints,
finish: toZone(finish),
finish: toZone(finish),
- kills: tagged.filter((z) => z.surf!.kind === 'kill').map(toZone),
+ kills: tagged.filter((t) => t.meta.kind === 'kill').map(toZone),
spawn: { x: spawn[0], y: spawn[1], z: spawn[2] },
spawn: { x: spawn[0], y: spawn[1], z: spawn[2] },
spawnYaw,
spawnYaw,
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/game/systems/triggers.test.ts'
+// covers: 'apps/server/src/game/systems'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'packages/gameplay/src/entities/trigger.test.ts'
+// covers: 'packages/gameplay/src/entities'
+// Generated by node test/run.js --index; do not edit.