spoint について
spointは、Spointエンジンでマルチプレイヤー3Dワールドアプリを開発するためのリファレンススキルです。インタラクティブなアリーナを作る開発者向けに、ワールド設定やサーバー・クライアントAPIを説明します。
主な機能
- 01ワールドとアプリの設定
- 02エンティティの生成とライフサイクル
- 03物理演算とコライダーAPI
- 04プレイヤー操作とメッセージング
- 05ゲームメカニクスのファクトリー
活用シーン
- 01マルチプレイヤーアリーナの構築
- 02ワールドモデルへのコライダー追加
- 03操作とプレイヤーイベントの実装
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ anentrypoint/ spoint --skill spointインストール後、Claude Code で/spointを入力して読み込まれたか確認します。
詳細
Spoint アプリ開発リファレンス
セットアップ:
npx spoint scaffold # first time — copies default apps/ into cwd
npx spoint # start server (localhost:3001)
npx spoint-create-app my-app
npx spoint-create-app --template physics my-crate
プロジェクト構成:apps/world/index.js(ワールド設定)+ apps/<name>/index.js(アプリ)。エンジンは npm から提供されます。ユーザープロジェクトには含めません。
アプリの設計原則:アプリは設定、エンジンはコードです。
- アプリがカスタム UI を描画する場合(render の戻り値に
ui:フィールドがある場合)を除き、clientブロックは不要です。スナップショットによって位置、回転、カスタム設定は自動的に引き継がれます。 onEditorUpdateは不要です。エディターからの位置、回転、スケール、カスタム設定の変更はエンジンが自動的に適用します。setStatic/setDynamicとaddBoxColliderを個別に呼び出す代わりに、ctx.physics.addColliderFromConfig(cfg)を使用してください。spawnと手動のteardownループを使う代わりに、ctx.world.spawnChild(id, cfg)を使用してください。子は自動的に破棄されます。- ヘルパー関数は
export default {}ブロックの外に配置してください(evaluateAppModuleによって巻き上げられます)。
クイックスタート — 最小限の動作するアリーナ
apps/world/index.js
export default {
port: 3001, tickRate: 64, gravity: [0, -9.81, 0],
movement: { maxSpeed: 4.0, groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0, jumpImpulse: 4.0 },
player: { health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, modelScale: 1.323, feetOffset: 0.027 },
scene: { skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200, sunIntensity: 1.5, sunPosition: [20, 40, 20] },
terrain: { seed: 0, octaves: 10, frequency: 0.07, amplitude: 0.5, renderDistance: 4, chunkSize: 10, resolution: 96 },
entities: [{ id: 'map', model: './apps/game/map.glb', position: [0,0,0], app: 'placed-model', config: { collider: 'trimesh' }, custom: { _interior: true } }],
spawnPoint: [0, 2, 0]
}
// terrain: false — disables terrain entirely
// All terrain fields optional — TERRAIN_DEFAULTS used for any omitted fields
apps/placed-model — マップ/インテリアモデルを配置し、実際のコライダーを設定する(静的メッシュはデフォルトで衝突します)
配置するマップやインテリアモデルに専用アプリは不要です。組み込みの placed-model アプリを使用し、
config.collider(box/sphere/capsule/convex/trimesh/none、GLB からベイクされる trimesh と box のフォールバック)
と custom._interior: true を指定すると、インテリア面の両面描画が有効になり、スナップショットに常に含まれます
(マップ/アリーナは内部のすべてのプレイヤーに常に表示され、通常のプロップのように距離によってカリングされません)。
apps/box-static/index.js — 再利用可能な静的ボックスプリミティブ
// Apps are config, not code. No client block needed — snapshot carries position/rotation/custom automatically.
export default {
server: {
setup(ctx) {
const c = ctx.config
const hx = c.hx ?? 1, hy = c.hy ?? 1, hz = c.hz ?? 1
ctx.entity.custom = { mesh:'box', color:c.color??0x888888, roughness:c.roughness??0.9, sx:hx*2, sy:hy*2, sz:hz*2 }
ctx.physics.addColliderFromConfig({ type:'box', size:[hx,hy,hz] })
}
}
}
ワールド設定スキーマ
すべてのフィールドは省略可能です。apps/world/index.js はプレーンオブジェクトをエクスポートします。
export default {
port: 3001, tickRate: 64, gravity: [0,-9.81,0],
entityTickRate: 16, // app update() Hz (default = tickRate). Lower = fewer update() calls, less CPU
physicsRadius: 0, // dynamic body LOD radius (0 = disabled). Bodies outside all players suspended from Jolt
movement: {
maxSpeed: 4.0, // code default is 8.0 — always override explicitly
groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0,
jumpImpulse: 4.0, // velocity SET (not added) on jump
crouchSpeedMul: 0.4, sprintSpeed: null // null = maxSpeed * 1.75
},
player: {
health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, crouchHalfHeight: 0.45,
mass: 120, modelScale: 1.323,
feetOffset: 0.027 // feetOffset * modelScale = negative Y on model
},
scene: {
skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200,
ambientColor: 0xfff4d6, ambientIntensity: 0.3,
sunColor: 0xffffff, sunIntensity: 1.5, sunPosition: [21,50,20],
fillColor: 0x4488ff, fillIntensity: 0.4, fillPosition: [-20,30,-10],
shadowMapSize: 1024, shadowBias: 0.0038, shadowNormalBias: 0.6, shadowRadius: 12, shadowBlurSamples: 8
},
camera: {
fov: 70, shoulderOffset: 0.35, headHeight: 0.4,
zoomStages: [0,1.5,3,5,8], defaultZoomIndex: 2,
followSpeed: 12.0, snapSpeed: 30.0, mouseSensitivity: 0.002, pitchRange: [-1.4,1.4]
},
animation: { mixerTimeScale: 1.3, walkTimeScale: 2.0, sprintTimeScale: 0.56, fadeTime: 0.15 },
entities: [{ id:'env', model:'./apps/my-app/env.glb', position:[0,0,0], app:'placed-model', config:{collider:'trimesh'}, custom:{_interior:true} }],
playerModel: '/assets/default-avatar.vrm',
spawnPoint: [0,2,0],
// Optional: RTCIceServer[] overriding wireweave's bundled default public STUN/TURN list for the
// P2P host/join voice+data bridge (?host / ?join / room links). Each entry is a real RTCIceServer:
// { urls: string|string[], username?: string, credential?: string }. STUN-only entries omit
// username/credential. Omitted entirely, or [], falls back to wireweave's own default (a public
// STUN server + a shared-credential demo TURN relay -- fine for local testing, not for a real
// hosted deployment since every NAT-restricted peer then depends on that shared free-tier relay).
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:your-turn-server.example.com:3478', username: 'user', credential: 'pass' }
]
}
アセット読み込みゲート
以下のすべてが同時に満たされるまで、読み込み画面が表示されます。
- WebSocket に接続されている
- プレイヤーの VRM がダウンロードされている
modelフィールドを持つエンティティ(またはメッシュを作成するエンティティ)が読み込まれている- 最初のスナップショットを受信している
modelフィールドを持つworld.entitiesのすべてのエントリーが読み込まれている
実行時に ctx.world.spawn() で生成されたエンティティは、読み込みゲートの対象外です。モデルの読み込み中に読み込み画面を表示したままにするには、world.entities で宣言してください。
リモートモデル — 確認済みファイル名
URL:https://raw.githubusercontent.com/anEntrypoint/assets/main/FILENAME.glb
ファイル名を推測しないでください。誤った URL はエラーなしで 404 になります。
broken_car_b6d2e66d_v1.glb broken_car_b6d2e66d_v2.glb crashed_car_f2b577ae_v1.glb
crashed_pickup_truck_ae555020_v1.glb crashed_rusty_minivan_f872ff37_v1.glb Bus_junk_1.glb
blue_shipping_container_60b5ea93_v1.glb blue_shipping_container_63cc3905_v1.glb
dumpster_b076662a_v1.glb dumpster_b076662a_v2.glb garbage_can_6b3d052b_v1.glb
crushed_oil_barrel_e450f43f_v1.glb fire_hydrant_ba0175c1_v1.glb
fire_extinguisher_wall_mounted_bc0dddd4_v1.glb
break_room_chair_14a39c7b_v1.glb break_room_couch_444abf63_v1.glb
break_room_table_09b9fd0d_v1.glb filing_cabinet_0194476c_v1.glb
fancy_reception_desk_58fde71d_v1.glb cash_register_0c0dcad2_v1.glb
espresso_machine_e722ed8c_v1.glb Couch.glb Couch_2.glb 3chairs.glb
large_rock_051293c4_v1.glb Tin_Man_1.glb Tin_Man_2.glb Plants_3.glb Urinals.glb V_Machine_2.glb
リモートモデルは読み込みゲートの対象外です。物理演算には prop-static アプリを使用してください。
// apps/prop-static/index.js — already built-in, no need to recreate
// Spawn:
const BASE = 'https://raw.githubusercontent.com/anEntrypoint/assets/main'
ctx.world.spawn('dumpster-1', { model:`${BASE}/dumpster_b076662a_v1.glb`, position:[5,0,-3], app:'prop-static' })
サーバー ctx API
ctx.entity
ctx.entity.id / model / position / rotation / scale / velocity / custom / parent / children / worldTransform
ctx.entity.destroy()
// position: [x,y,z] rotation: [x,y,z,w] quaternion custom: any (sent in every snapshot — keep small)
ctx.state
ホットリロード後も保持されます。セットアップのたびにタイマーとバスのサブスクリプションを再登録してください。
setup(ctx) {
ctx.state.score = ctx.state.score || 0 // || preserves value on reload
ctx.state.data = ctx.state.data || new Map()
ctx.bus.on('event', handler) // always re-register
ctx.time.every(1, ticker) // always re-register
}
ctx.config
エンティティのエディター表示用状態のライブオーバーレイです。ctx.config は、ワールド定義の config:{...} と、ctx.entity.custom に含まれる _ で始まらないすべてのキーを読み込みます
(そのため、制作者がインスペクターで編集する editorProps フィールドもここに表示されます)。ワールドで { id:'x', app:'y', config:{ radius:5 } } と設定すると、ctx.config.radius で参照できます。エディターでプロップをライブ編集すると、ctx.config が更新され、ctx.onConfigChange(cb) のリスナーが呼び出されます。setup の再実行は不要です。
setup(ctx) {
ctx.onConfigChange(cfg => { /* re-read cfg.radius etc. — editor prop changed live */ })
}
ctx.interactable
近接判定、E キーのプロンプト、クールダウンはエンジンが処理します。アプリで必要なのは onInteract だけです。
setup(ctx) { ctx.interactable({ prompt:'Press E', radius:2, cooldown:1000 }) },
onInteract(ctx, player) { ctx.players.send(player.id, { type:'opened' }) }
ctx.physics
// One-call setup — handles motion type + collider in one step (preferred)
ctx.physics.addColliderFromConfig({
type: 'box'|'sphere'|'capsule'|'convex'|'trimesh'|'none',
size: [hx,hy,hz], // box only
radius: 0.5, // sphere/capsule
height: 1.8, // capsule (full height)
meshIndex: 0, // convex: GLB mesh index
mass: 10, // dynamic only
dynamic: true, // motion type flag (default: static)
kinematic: true, // alternative motion type
linearDamping: 1.5,
angularDamping: 4.0,
})
// Low-level methods (use addColliderFromConfig instead for new code)
ctx.physics.setStatic(true) / setDynamic(true) / setKinematic(true)
ctx.physics.setMass(kg) // kg: NonNegFinite
ctx.physics.addBoxCollider([hx,hy,hz])
ctx.physics.addSphereCollider(radius) // radius: NonNegFinite
ctx.physics.addCapsuleCollider(radius, fullHeight) // radius, fullHeight: NonNegFinite
ctx.physics.addTrimeshCollider() // static-only, uses entity.model
ctx.physics.addConvexCollider(points) // flat [x,y,z,...], all motion types
ctx.physics.addConvexFromModel(meshIndex=0) // extracts verts from entity.model GLB
ctx.physics.addForce([fx,fy,fz]) // velocity += force/mass
ctx.physics.setVelocity([vx,vy,vz])
ctx.physics.getVelocity() / getAngularVelocity() / getRotation() // read this body's live motion
ctx.physics.isAtRest(eps=0.05) // settled? (linear+angular speed below eps) — tower-topple lose check
ctx.physics.tiltFromUpright() // radians from vertical (toppled piece ~PI/2)
ctx.physics.setFriction(f) / setRestitution(b)// per-body friction / bounciness at runtime
ctx.world/ctx.physics を使った、エンティティ間および中心からずれた位置での物理演算:
ctx.world.applyImpulse(entityId, [ix,iy,iz], worldPoint?) // worldPoint => torque (off-centre hit)
ctx.world.setVelocity(entityId, [vx,vy,vz])
ctx.world.setPosition(entityId, [x,y,z], rotation?) // kinematic move (entity + collider) — moving platforms
ctx.world.setGravityFactor(entityId, factor) // 1 = normal, 0 = float, <0 = anti-gravity
ctx.world.weld(entityA, entityB, opts?) // fixed constraint
ctx.world.joint(entityA, entityB, { type:'point'|'distance'|'hinge', ... })
ctx.world.removeConstraint(constraintId)
ctx.world
ctx.world.spawn(id, config) // id: string|null (null=auto-generate). Returns entity|null.
ctx.world.spawnChild(id, config) // like spawn, but auto-destroyed when parent app tears down
ctx.world.destroy(id)
ctx.world.getEntity(id) // entity|null
ctx.world.query(filterFn) // entity[]
ctx.world.nearby(pos, radius) // entity IDs within radius
ctx.world.reparent(eid, parentId) // parentId null = detach
ctx.world.attach(entityId, appName) / detach(entityId)
ctx.world.gravity // [x,y,z] read-only
// spawn config keys: model, position, rotation, scale, parent, app, config, autoTrimesh
ctx.players
ctx.players.getAll()
// Player: { id, state: { position, velocity, health, onGround, crouch, lookPitch, lookYaw, interact } }
ctx.players.getById(id) // Player|null — direct lookup
ctx.players.getNearest([x,y,z], radius) // Player|null
ctx.players.send(playerId, msg) // client receives in onEvent(payload, engine)
ctx.players.broadcast(msg)
ctx.players.broadcastNearby(pos, radius, msg) // send to all players within radius of pos
ctx.players.setPosition(playerId, [x,y,z]) // teleport — no collision check
ctx.players.setName(playerId, name) // server-authoritative display name; read via getById(id).name
ctx.players.setAppearance(playerId, { tint, nameTag }) // per-player mesh tint (0xRRGGBB|null) + nameplate
ctx.players.setModel(playerId, url) // swap one player's whole avatar VRM/GLB (skins, team models)
ctx.players.setMovementOverride(playerId, { maxSpeed, jumpImpulse, ... }) // null = clear
ctx.players.onPlayerContact(radius, cb) // cb(idA,idB) per unordered in-radius pair/tick — tag/sumo/dodgeball
ctx.players.nearestOtherPlayer(playerId, radius) // nearest player excluding self, or null
ctx.players.setLifecycle(playerId, 'alive'|'frozen'|'spectator', { spectateTarget }) // elimination/freeze-tag
ctx.players.playAnimation(playerId, clip, { loop, fade }) // force a clip on that player's avatar
ctx.players.attachEntity(playerId, entityId, [ox,oy,oz]) // entity follows the player (flag carry, held tool)
ctx.players.detachEntity(entityId)
player.state.health/player.state.velocity を直接変更すると、次のスナップショットで反映されます。
クライアント側の追従カメラ:engine.spectate(targetPlayerId)(自分の視点に戻るには null を渡します)。
ctx.bus
ctx.bus.on('channel', (e) => { e.data; e.channel; e.meta })
ctx.bus.once('channel', handler)
ctx.bus.emit('channel', data) // meta.sourceEntity set automatically
ctx.bus.on('combat.*', handler) // wildcard prefix
ctx.bus.handover(targetEntityId, data) // fires onHandover on target
system.* プレフィックスは予約済みです。このプレフィックスでイベントを発行しないでください。
ctx.time
ctx.time.tick / deltaTime / elapsed
ctx.time.after(seconds, fn) // one-shot, cleared on teardown
ctx.time.every(seconds, fn) // repeating, cleared on teardown
ctx.raycast
const hit = ctx.raycast([x,y,z], [dx,dy,dz], maxDist, excludeBodyId?)
// { hit:bool, distance:number, position:[x,y,z]|null, normal:[x,y,z]|null,
// bodyId:number|null, entityId:string|null }
// entityId attributes the hit to a spawned entity (null = terrain/unowned body) —
// this is what a shoot/click/grab-a-target game reads to know WHAT was hit.
ctx.define* — 再利用可能なゲームメカニクスのファクトリー
update() で一般的なメカニクスを独自に実装するより、こちらを優先してください(すべて仕様を受け取り、update/onInteract から操作する状態付きハンドルを返します):
ctx.defineGameFSM(spec) // declarative match phases (any custom state machine) — xstate5
ctx.defineGameMode(spec) // lobby/warmup/rounds/end match-loop skeleton over defineGameFSM+defineTeams
// ({teams?,minPlayers,countdownMs,roundMs,intermissionMs,roundsPerMatch,winCondition,scoring})
// -> .teams/.round/.addScore/.getScore/.getScores + defineGameFSM's own runtime
ctx.defineHealth(spec) // HP / damage / death / respawn
ctx.defineSteering(spec) // seek / arrive / separation / ground-clamp AI movement
ctx.defineCheckpoint(spec) // per-player checkpoint + fall-respawn
ctx.defineBuffStack(spec) // stackable timed buffs/debuffs
ctx.defineShrinkingZone(spec) // battle-royale storm circle
ctx.definePickup(spec) // nearest-player-in-radius collect trigger ({radius,onCollect,oneShot,cooldown})
ctx.defineDestructible(spec) // health + impact-damage + debris + respawn for a breakable object
ctx.defineTeams(spec) // server-authoritative team membership + shared scoreboard (CTF/TDM/KotH/domination)
// ({teams,autoBalance,scoreLimit,onAssign,onScore,onWin}) -> assignPlayer/addScore/leader/getScores
ctx.defineWeapon(spec) // hitscan/ammo/reload/killfeed combat ({damage,range,magazine,reloadMs,fireRateMs,onFire,onHit,onKill})
// -> fire(shooterId,origin,dir,opts)/reload()/canFire()/ammo
ctx.definePlayerInventory(spec)// per-player items + currency/buy-phase ({startItems,startCurrency,catalog,onChange,onBuy})
// -> add/remove/count/has/currency/earn/spend/buy/push(pid)
ctx.definePath(points) // ordered-waypoint path -> length/pointAt(d)/progressAt(pos)/directionAt(d) (race/patrol/payload)
配置可能な目標アプリ(tps-game の placeableApps で、エディターから配置して設定します):
capture-zone(KotH/ドミネーションの進行度)、waypoint(順序付き経路マーカー。collectWaypoints(ctx) で読み取ります)、shrinking-zone(バトルロイヤルのストームサークル)。
各仕様のフィールドについては、src/behaviours/README.md を参照してください。
ctx.onPlayerProximity / ctx.onConfigChange
ctx.onPlayerProximity(radius, cb) // cb(ctx, playerId) once/tick per player within radius of this entity
ctx.onConfigChange(cb) // cb(config) when the editor edits an editorProp live (no re-setup)
ctx.canSee — 視線判定
ctx.canSee(fromPos, toPos, { excludeBodyId?, targetEntityId?, maxDistance?, tolerance? })
// true if toPos is visible from fromPos with nothing solid between them (raycast-based).
// Stealth vision cones, hide-and-seek detection, red-light-green-light "is the mover in view".
ctx.storage
if (ctx.storage) {
await ctx.storage.set('key', value)
const val = await ctx.storage.get('key')
await ctx.storage.delete('key')
const keys = await ctx.storage.list('')
}
クライアント API
render(ctx) — 戻り値
{ position:[x,y,z], rotation:[x,y,z,w], model:'path.glb', custom:{...}, ui:ctx.h('div',...) }
render(ctx) — 利用可能なフィールド
ctx.entity // { id, position, rotation, custom, model }
ctx.state // alias for ctx.entity.custom
ctx.players // array of all player states
ctx.h // hyperscript createElement
ctx.network.send(msg) // send message to server (entityId auto-attached)
ctx.engine // full engineCtx (see below)
// Three.js shortcuts — same as ctx.engine.* but directly accessible:
ctx.THREE // THREE namespace (BoxGeometry, MeshStandardMaterial, etc.)
ctx.scene // THREE.Scene — ctx.scene.add(mesh)
ctx.camera // THREE.PerspectiveCamera
ctx.renderer // THREE.WebGLRenderer
ctx.playerId // local player ID string
ctx.clock // { elapsed: seconds since page load }
engine オブジェクト(setup/onFrame/onInput/onEvent/onKeyDown/onKeyUp の第 2 引数)
engine.THREE / scene / camera / renderer
engine.playerId // local player ID
engine.network.send(msg) // send message to server from any hook
engine.client.state // { players:[...], entities:[...] }
engine.cam.getAimDirection(position) // normalized [dx,dy,dz]
engine.cam.punch(intensity) // visual recoil
engine.players.getAnimator(playerId)
engine.players.setExpression(playerId, name, weight)
engine.players.setAiming(playerId, isAiming)
engine.pickGround(clientX, clientY) // world point under a screen coord -> [x,y,z]|null (RTS/tower/golf)
engine.followEntity(entityId, { distance, height }) // orbit cam around a non-player entity (ball-cam); null = back to player
engine.spectate(targetPlayerId) // follow-cam another player; null = own view
アプリフック — 全リスト
setup(engine) // called once on module load
render(ctx) // called every 250ms, returns render state + ui
onInput(input, engine) // called every input tick (60Hz)
onEvent(payload, engine) // called on server broadcast
onFrame(dt, engine) // called every animation frame
onMouseDown(e, engine) // mouse button press on canvas
onMouseUp(e, engine) // mouse button release on canvas
onKeyDown(e, engine) // keyboard key press (document-level)
onKeyUp(e, engine) // keyboard key release (document-level)
ctx.h — hyperscript + Ripple UI
ctx.h(tag, props, ...children) // props = attrs/inline styles or null; null children ignored
ctx.h('div', { style:'color:red' }, 'Hello')
Ripple UI の CSS は読み込み済みです。DaisyUI 互換のクラス名をそのまま使用してください:
ctx.h('button', { class: 'btn btn-primary' }, 'Click me')
ctx.h('div', { class: 'card bg-base-200 shadow-xl' },
ctx.h('div', { class: 'card-body' },
ctx.h('h2', { class: 'card-title' }, 'Hello'),
ctx.h('button', { class: 'btn btn-sm btn-error' }, 'Delete')
)
)
クライアントアプリでは import を使用できません。評価前にすべての import 文が削除されます。依存関係には engine.* を使用してください。
onInput フィールド
forward backward left right jump crouch sprint shoot reload interact yaw pitch
Webcam AFAN — 遅延読み込みの顔追跡
オプトインの場合のみ有効です。起動時には読み込まれません。webcam-avatar アプリから有効にするか、手動で有効にしてください:
// In setup(engine) or onKeyDown — only loaded on demand
if (!window.enableWebcamAFAN) await import('/webcam-afan.js')
const tracker = await window.enableWebcamAFAN((data) => {
// data: Uint8Array(52) — ARKit blendshape weights, each byte = weight * 255
engine.network.send({ type: 'afan_frame', data: Array.from(data) })
})
// tracker.stop() to release camera
サーバーでは、近くにいるプレイヤーに afan_frame を転送してください。onAppEvent 経由でペイロード { type: 'afan_frame', playerId, data } を受信すると、エンジンは受信したフレームを対象の VRM モーフターゲットに自動適用します。
プロシージャルメッシュ(カスタムフィールド)
GLB が設定されていない場合、custom でジオメトリを生成します。GLB ファイルなしでプリミティブを作成する主な方法です。
{ mesh:'box', color:0xff8800, roughness:0.8, sx:2, sy:1, sz:2 } // sx/sy/sz = FULL dimensions
{ mesh:'sphere', color:0x00ff00, r:1, seg:16 }
{ mesh:'cylinder', r:0.4, h:0.1, seg:16, color:0xffd700, metalness:0.8,
emissive:0xffa000, emissiveIntensity:0.3,
light:0xffd700, lightIntensity:1, lightRange:4 }
{ ..., hover:0.15, spin:1 } // Y oscillation amplitude (units), rotation (rad/sec)
{ ..., glow:true, glowColor:0x00ff88, glowIntensity:0.5 }
{ mesh:'box', label:'PRESS E' }
sx/sy/sz は全サイズです。addBoxCollider は半径寸法を受け取ります。 sx:4,sy:2 → addBoxCollider([2,1,...])
AppLoader — ブロック対象文字列
これらのいずれかがソース内のどこかに含まれていると(コメント内も含む)、例外を出さずに読み込みが抑止されます:
process.exit child_process require( __proto__ Object.prototype globalThis eval( import(
重大な注意事項
物理演算が有効になるのは、アプリの setup() 内だけです。 アプリから ctx.physics.* が呼び出されていない場合、entity.bodyType = 'static' を設定しても何も起こりません。
// WRONG — entity renders but players fall through:
const e = ctx.world.spawn('floor', {...}); e.bodyType = 'static' // ignored
// CORRECT:
ctx.world.spawn('floor', { app:'box-static', config:{ hx:5, hy:0.25, hz:5 } })
maxSpeed のデフォルト値が一致していません。 コード上のデフォルト値は 8.0 です。movement.maxSpeed は必ず明示的に設定してください。
水平方向の速度は希望速度ベースです。 物理演算のステップ後、希望速度が XZ の物理演算結果を上書きします。player.state.velocity[0/2] は希望速度です。物理演算から得られるのは velocity[1](Y)のみです。
カプセルの引数順。 addCapsuleCollider(radius, fullHeight) は全高を受け取り、内部で半分にします。半高、半径の順で受け取る Jolt の直接 API とは逆です。
Trimesh は静的な用途専用です。 動的/キネマティックには addConvexCollider または addConvexFromModel を使用してください。
ホットリロード時に setTimeout はクリアされません。 ctx.time.* はクリアされます。生のタイマーは teardown で手動管理してください。
親を破棄すると、すべての子も破棄されます。 子を保持するには、先に親子関係を変更してください:ctx.world.reparent(childId, null)
setPosition は壁を通り抜けてテレポートします。次のティックで物理演算により押し戻されます。
アプリの球体衝突判定は O(n²) です。 インタラクティブなエンティティの数は約 50 未満にしてください。
スナップショットはプレイヤーが 1 人以上いる場合にのみ送信されます。 エンティティの状態は更新されますが、何もブロードキャストされません。
TickSystem の 1 ループあたりの最大ステップ数は 4 です。 4 ティックを超えて遅れると(64TPS で約 62ms)、通知なくドロップされます。
プレイヤーの参加/退出は onMessage 経由で届きます:
onMessage(ctx, msg) {
if (!msg) return
const pid = msg.playerId || msg.senderId
if (msg.type === 'player_join') { /* ... */ }
if (msg.type === 'player_leave') { /* ... */ }
}
デバッグ用グローバル
Server (Node REPL): globalThis.__DEBUG__.server
Client (browser): window.debug → scene, camera, renderer, client, players, input
RTC(wireweave)
Spoint の WebRTC スタックは wireweave です(npm i wireweave nostr-tools)。用途:URL ハッシュを使ったページ内音声チャット、P2P ゲームフレームのホスト/参加、セルフホスト可能なサーバーでのプレイヤー間サイドチャネル。シグナリングサーバーは不要です。公開 nostr リレー上で探索します。
import { createWireweaveClient, getRoomFromHash } from 'spoint/transport/WireweaveClient.js'
const ww = await createWireweaveClient({
namespace: 'mygame',
room: getRoomFromHash() || 'lobby',
displayName: 'Player',
voice: true // also opens VoiceSession (mic prompt)
})
ww.data.addEventListener('peer-open', e => console.log('peer up', e.detail.peerPubkey))
ww.data.addEventListener('data', e => onFrame(e.detail.peerPubkey, e.detail.data))
ww.data.broadcast(new Uint8Array(payload))
ピアごとの TransportWrapper(RTCDataChannelTransport を使用するコードにそのまま導入できます):
import { WireweaveTransport } from 'spoint/transport/WireweaveTransport.js'
const t = new WireweaveTransport({ session: ww.data, peerPubkey })
t.on('message', buf => ...); t.send(bytes)