@homie-rocks/studio 0.45.2 → 0.45.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/guides/game/RULES.md +222 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +42 -0
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +105 -21
- package/rules/guard.ts +1 -1
- package/rules/host.ts +40 -9
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +89 -23
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/index.mjs +29 -1
- package/worker/players.mjs +3 -1
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,35 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.45.3] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
**Plugin 0.45.3**
|
|
23
|
+
|
|
24
|
+
Run richer server-authoritative combat, admission and bots with shared collision queries.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Opt-in ray options, ordered `rayAll` hits, named hit regions, projectile profiles, immutable query scenes and scratch movement queries.
|
|
29
|
+
- `f.pulse()` carries a nonzero byte action once over the reliable input-event channel. Use it for weapon selection; ordinary sampled fields must not carry one-frame actions.
|
|
30
|
+
- Entity field visibility: `owner`, `server` and `results`. Per-recipient projections also apply to welcome, watching and reconnect snapshots. Private-delivery deltas refer to a keyframe so packet loss cannot leave moving actors at stale poses.
|
|
31
|
+
- Authenticated room round records, with a durable retry outbox and idempotent player-history append. Browser saves cannot overwrite server records.
|
|
32
|
+
- Budgeted routes over baked walking graphs, ordered same-tick dispatch, player labels, ranked takeover and result-free retries. Named `navigation` graphs validate once at compilation, avoiding repeated graph-validation charges in bot handlers while retaining live obstacle checks and full searches.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **Ray compatibility:** the earlier PR revision changed all rays to hit inside solids and charged more. Three-argument calls now retain their existing results and cost. Supplying the new options explicitly opts into inside-solid hits and the new query cost.
|
|
37
|
+
- **Collider delivery:** the earlier PR revision copied every primitive field. Collider rows now carry only explicitly declared public `collider.fields`, and geometry revisions are sent only when changed relative to the snapshot baseline.
|
|
38
|
+
- **Build compatibility:** `label`, `dispatch`, `rayAll`, `rays` and `restart` remain legal ordinary property and destructuring names. Guarding capability calls does not reserve these data names.
|
|
39
|
+
- **Terrain compatibility:** the earlier PR revision changed terrain casting globally. Existing casts retain the 0.45.0 implementation; `body.terrain: 'exact'` and ray option `terrain: 'exact'` opt into the new algorithm and cost. Seeded compatibility tests pin old results and costs.
|
|
40
|
+
- Client-requested actor rewind is bounded by server-measured round-trip latency plus presentation and scheduling margins, within the declared history limit. A client cannot simply choose any point in the history window.
|
|
41
|
+
- Optional `sweep(body, delta, {ground:false})` skips redundant support work when the mover explicitly computes grounding. Default sweep behavior is unchanged. Exact terrain casts reject irrelevant support features early, reuse face-entry calculations and cache identical immutable queries within a handler. Ten thousand seeded exact casts pin zero result drift through the face optimization. Near-first tile traversal cuts charged work by 30% across 2,000 seeded dense-terrain casts; its maximum contact-component drift is 2.6e-14. These opt-in savings do not enlarge handler budgets.
|
|
42
|
+
|
|
43
|
+
### Upgrade notes
|
|
44
|
+
|
|
45
|
+
- New ray and terrain policies are opt-in. Query filters and locally predicted feedback do not grant damage authority.
|
|
46
|
+
- `server:` save keys are reserved for declared room records; player-owned saves use other keys. Records retain at most 100 completed rounds.
|
|
47
|
+
- This capability work does not itself constitute a Stormbreak gameplay parity claim.
|
|
48
|
+
|
|
20
49
|
## [0.45.2] - 2026-10-10
|
|
21
50
|
|
|
22
51
|
**Plugin 0.45.2** · [#76](https://github.com/homie-rocks/homie/pull/76)
|
package/guides/game/RULES.md
CHANGED
|
@@ -685,8 +685,7 @@ score and other shared outcomes still arrive with the network. The studio choose
|
|
|
685
685
|
Room state is saved automatically (default movementSeconds: 1, plus round end,
|
|
686
686
|
pause and finish). After 60 seconds with no person the room ends even if screens or
|
|
687
687
|
AI remain. Use browser createSaves for player-owned character progress; it is not
|
|
688
|
-
server-verified money.
|
|
689
|
-
Use a separate authorized records service for private or lasting app records.
|
|
688
|
+
server-verified money. Entity fields default to public. Declare visibility explicitly for private information (below); shared state and motion remain public. Use declared server round records for verified match history.
|
|
690
689
|
|
|
691
690
|
|
|
692
691
|
## Spatial delivery (0.45.0; milestone 2 slice 2)
|
|
@@ -772,3 +771,224 @@ known geometry beyond received ticks: a client cannot predict an unseen remote
|
|
|
772
771
|
build/destruction. Once that update arrives, replay uses its geometry immediately.
|
|
773
772
|
Moving platforms use world.place in their handlers; sweep/support see each tick's
|
|
774
773
|
position. Passenger carrying is explicit movement logic, not automatic physics.
|
|
774
|
+
|
|
775
|
+
## Filtered combat and camera queries
|
|
776
|
+
|
|
777
|
+
`world.ray(from, direction, metres, options?)` returns the nearest hit or undefined.
|
|
778
|
+
`world.rayAll` returns all entry hits in distance order (one per shape or named
|
|
779
|
+
part, not exit faces). Static geometry wins equal-distance ties; entity and part
|
|
780
|
+
insertion order break remaining ties. Both queries charge their scans, geometry,
|
|
781
|
+
filtering and output to the normal handler budget. The maximum ray distance is 10,000 metres; radius queries keep their 64 m bound; no query raises the game's budget.
|
|
782
|
+
|
|
783
|
+
```ts
|
|
784
|
+
runner: {
|
|
785
|
+
// Movement remains a capsule; these boxes apply only to ray queries.
|
|
786
|
+
body: {shape: 'capsule', radius: .42, height: 1.8, maxSpeed: 22},
|
|
787
|
+
query: {layer: 'fighters', tags: ['damageable'], parts: {
|
|
788
|
+
body: {shape: 'box', radius: .42, height: 1.5},
|
|
789
|
+
head: {shape: 'box', radius: .24, height: .4, offset: {x: 0, y: 0, z: 1.5}},
|
|
790
|
+
}},
|
|
791
|
+
fields: {hp: f.fix({init: 100})},
|
|
792
|
+
// ...player, inputs, movement and handlers...
|
|
793
|
+
}
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
Parts are axis-aligned, feet-relative shapes, at most 16 per kind. `hit.part`
|
|
797
|
+
is their declared name. A kind without parts uses its normal body or enabled
|
|
798
|
+
collider. Parts do not resize movement bodies. Optional `query.profiles` holds up to eight named part sets; `profile` in ray options selects one, falling back to the default parts when that kind has no matching profile. For example, projectile travel may use a full-height box while bullets use separate body/head boxes. Geometry-only rays always use collision shapes. Query layers and tags are static
|
|
799
|
+
kind declarations. A live collider's `enabled` and `size` fields still control
|
|
800
|
+
its presence and dimensions.
|
|
801
|
+
|
|
802
|
+
Options are plain data, never callbacks:
|
|
803
|
+
|
|
804
|
+
- `kind`, `tag`, `layer`: exact entity-kind, declared-tag and layer matches.
|
|
805
|
+
Undeclared layers default to `geometry` for live colliders and `body` otherwise.
|
|
806
|
+
Static map shapes use `geometry` and are not subject to kind/tag/field filters.
|
|
807
|
+
- `where: {hp: {gt: 0}}`: filter declared entity fields. Values may be exact
|
|
808
|
+
numbers, strings or booleans; numeric tests are `gt`, `gte`, `lt`, `lte`, plus
|
|
809
|
+
`eq`. All tests must match. Missing fields do not match. At most 16 fields.
|
|
810
|
+
- `ignore: [ref]`: at most 16 opaque entity refs. Entity-handler and movement rays ignore their own entity
|
|
811
|
+
by default; `ignoreSelf: false` includes it. This also applies when a moving
|
|
812
|
+
non-player entity is itself a live collider. Local view rays use the own player
|
|
813
|
+
as caller; room-handler rays have no implicit caller. Rocket travel can ignore its owner explicitly; blast damage
|
|
814
|
+
may include the owner.
|
|
815
|
+
- `geometryOnly: true`: map plus enabled live colliders, using their collision
|
|
816
|
+
shape rather than hit parts. Fighters do not shield other fighters. Use an
|
|
817
|
+
exclusion when testing the visibility of the cover being damaged. Evaluate
|
|
818
|
+
every visibility ray before sending damage events to preserve pre-blast cover.
|
|
819
|
+
- `entitiesOnly: true`: omit the static map. Combine with the filters above.
|
|
820
|
+
- `radius` and `shape`: cast a sphere (default) or axis-aligned box with this
|
|
821
|
+
half-size around the ray centre, useful for camera clearance. Radius defaults
|
|
822
|
+
to zero and is at most 100 metres. A point ray beginning inside a solid reports
|
|
823
|
+
an immediate hit; movement sweeps continue to allow escape from overlap.
|
|
824
|
+
|
|
825
|
+
`room.ray` and `room.rayAll` use the same implementation for local aiming and
|
|
826
|
+
cosmetic feedback. They use the drawn entity poses and latest known live geometry;
|
|
827
|
+
only server results award damage. Spatially absent non-collider entities cannot
|
|
828
|
+
be targeted by the view. Unknown remote edits are not predicted.
|
|
829
|
+
|
|
830
|
+
Movement's `ctx.world.ray/rayAll` (also on `ctx.map`) query its tick's frozen static
|
|
831
|
+
and live collision geometry with these same options. Collider kind, layer, tags
|
|
832
|
+
and scalar fields accompany collision revisions, including outside visual
|
|
833
|
+
interest, so a predicted geometry filter has the same data as authority.
|
|
834
|
+
Movement cannot query fighters or award damage. These APIs do not implement lag
|
|
835
|
+
compensation: they query the current authoritative or presented world.
|
|
836
|
+
|
|
837
|
+
### Consent before driving an inherited bot
|
|
838
|
+
|
|
839
|
+
A player's declaration may set `control: 'accepted'`, naming a declared bit in
|
|
840
|
+
`motion`. When false, authority calls `think` even for a present person, and the
|
|
841
|
+
view draws the authoritative body without predicting that person's movement.
|
|
842
|
+
Commands remain available: validate an explicit consent command and set the bit
|
|
843
|
+
true. Initialize it on arrival and reset it in `on.takeover` when appropriate.
|
|
844
|
+
The server still owns driver/seat identity; this flag only controls input.
|
|
845
|
+
|
|
846
|
+
Set `takeover: 'fitness'` to name a numeric field used when selecting a fill bot.
|
|
847
|
+
The highest value wins (equal values use the highest seat). Update it in rules
|
|
848
|
+
from health, position or whatever makes a body suitable. Reserved companions
|
|
849
|
+
never yield. The selected entity keeps its ID, inventory and all state; only its
|
|
850
|
+
seat and driver change. A displaced bot retains its own state at the donor's old
|
|
851
|
+
seat. Without this declaration, existing seat-based selection is unchanged.
|
|
852
|
+
|
|
853
|
+
Movement queries reject distant live colliders by swept bounds before detailed
|
|
854
|
+
intersection, just as static map queries do. Bounds include capsule radius and
|
|
855
|
+
height, vertical columns and the full sweep, preserving fast travel and support.
|
|
856
|
+
|
|
857
|
+
### Ordered same-tick interactions
|
|
858
|
+
|
|
859
|
+
`world.dispatch(id, event, payload)` delivers a declared event immediately and
|
|
860
|
+
returns whether a live receiver had a handler. The receiver changes only its own
|
|
861
|
+
state; after it returns, later queries see those changes. Use it for ordered
|
|
862
|
+
pellets, where destroying a wall or killing a fighter changes the next ray.
|
|
863
|
+
Ordinary `send` retains next-tick delivery. For a blast, collect every visibility
|
|
864
|
+
decision before dispatching damage so one broken wall still shields that blast.
|
|
865
|
+
|
|
866
|
+
Dispatch consumes the caller's remaining handler budget, including receiver work,
|
|
867
|
+
and permits at most eight nested deliveries. It does not grant writes to another
|
|
868
|
+
entity or shared state. It is not a transaction: mutations before an error remain
|
|
869
|
+
subject to ordinary field coercion. Missing targets return false; there is no
|
|
870
|
+
queued undeliverable event for immediate delivery. Movement cannot dispatch.
|
|
871
|
+
|
|
872
|
+
Solid-terrain capsule distance queries reject triangle faces whose bounds cannot
|
|
873
|
+
beat the closest feature already found. This preserves exact sampled geometry,
|
|
874
|
+
including both source triangles and the solid sides and underside.
|
|
875
|
+
|
|
876
|
+
### Bounded actor-pose rewind
|
|
877
|
+
|
|
878
|
+
Declare `room.historySeconds: 1` (maximum two seconds). The server retains and
|
|
879
|
+
saves non-collider body positions and placement revisions on its virtual clock.
|
|
880
|
+
The view's `room.viewTick` is the tick used to interpolate remote bodies. Send it
|
|
881
|
+
with an action, then use `world.ray(..., {atTick, ...filters})`. Fractional ticks
|
|
882
|
+
interpolate saved poses. Player requests clamp to the smaller of the configured window and the server-measured round-trip latency plus 150 ms presentation delay and one simulation tick. Without a measurement only that small margin is allowed. The relay measures a nonce challenge; client-supplied latency figures are ignored.
|
|
883
|
+
A placement or new round prevents rewinding through a teleport or reset.
|
|
884
|
+
|
|
885
|
+
Rewind changes actor poses only. Eligibility filters use current fields, so a
|
|
886
|
+
corpse or a fighter killed by an earlier pellet cannot absorb later pellets.
|
|
887
|
+
Static terrain and live cover stay current: new cover blocks an old shot, and
|
|
888
|
+
destroyed cover no longer blocks. This conservative cover policy is explicit;
|
|
889
|
+
it is not a historical replay of world destruction. Rewind does not move actors,
|
|
890
|
+
change movement prediction, roll back damage or rewind projectiles. Choose the
|
|
891
|
+
window in game rules and validate actions normally; the client's tick is a claim.
|
|
892
|
+
|
|
893
|
+
Vertical capsule sweeps against solid terrain use an exact swept sphere against
|
|
894
|
+
the prism extended by the capsule centreline. Face, edge and vertex contacts
|
|
895
|
+
replace iterative grazing convergence; geometry and handler budgets stay intact.
|
|
896
|
+
|
|
897
|
+
For many related visibility or navigation queries in one handler, use
|
|
898
|
+
`const scene = world.rays(options)`, then `scene.ray(from, direction, metres)`
|
|
899
|
+
or `scene.rayAll(...)`. It captures actor poses, live geometry and filter fields
|
|
900
|
+
once; later damage or placement does not change that scene. Each ray is still
|
|
901
|
+
budgeted. Keep it local to the handler. Use ordinary `world.ray` when subsequent
|
|
902
|
+
hits must see mutations, such as sequential pellets. Predicted movement exposes
|
|
903
|
+
`ctx.world.rays` for its current geometry, too.
|
|
904
|
+
|
|
905
|
+
`world.label(playerRef)` reads the relay's current display name (including bot
|
|
906
|
+
names), at most 40 characters, or an empty string for a missing/non-player ref.
|
|
907
|
+
Copy it into a declared field when a historical message must survive departures
|
|
908
|
+
or seat exchanges. Names are presentation text, never proof of player identity;
|
|
909
|
+
use the existing owner/ref for identity. The host restores labels with its save.
|
|
910
|
+
|
|
911
|
+
For a body's planned route, capture `world.rays({geometryOnly:true,
|
|
912
|
+
shape:'capsule', radius:.4, height:1.8})`. A capsule ray starts at its centre;
|
|
913
|
+
`scene.sweep(scratchBody, delta)` and `scene.support(scratchBody, distance)` use
|
|
914
|
+
its foot position instead, like shared movement. A scratch body has plain `pos`
|
|
915
|
+
and `grounded` fields. These bounded queries change only that local copy, never
|
|
916
|
+
a player or the captured scene. Use the same movement helper to evaluate a
|
|
917
|
+
jump or dive without granting an action to the real body. Height is at least
|
|
918
|
+
twice the radius, at most 200 metres; spherical casts retain their diameter.
|
|
919
|
+
|
|
920
|
+
`world.round.restart()` is room-only. It abandons the current attempt and starts the
|
|
921
|
+
next round on the next tick, keeping seats and entities and running the normal
|
|
922
|
+
`roundStart` handlers. It publishes no completed results or `roundOver` event and
|
|
923
|
+
skips the intermission. Use it for an authorized retry; validate eligibility in
|
|
924
|
+
your room handler. Pending restart intent survives save/restore.
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
### Discrete actions with a value
|
|
928
|
+
|
|
929
|
+
Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
|
|
930
|
+
`room.input({select: 2})` on the press and return to zero on release. A nonzero
|
|
931
|
+
value is queued once, including a pulse shorter than a simulation tick; held
|
|
932
|
+
samples do not create repeated actions. Zero is neutral. The reliable event
|
|
933
|
+
channel carries these actions, with at most 16 pending per seat and one consumed
|
|
934
|
+
per tick. Epoch changes discard old-round actions. `f.press()` remains the
|
|
935
|
+
boolean equivalent; `f.u8()` is sampled state and can miss a one-frame pulse.
|
|
936
|
+
|
|
937
|
+
### Private entity fields and verified round history
|
|
938
|
+
|
|
939
|
+
Declare an entity field with `{visibility: 'server'}` for bot brains or secrets,
|
|
940
|
+
`{visibility: 'owner'}` for private inventory, or `{visibility: 'results'}` for
|
|
941
|
+
statistics revealed during intermission. Nested contents follow their top-level
|
|
942
|
+
field. Hidden values become neutral on the client; never depend on them in shared
|
|
943
|
+
movement. Server rules still read full state. Watchers receive public fields and
|
|
944
|
+
results only. Entity positions, driver information, shared state and motion are
|
|
945
|
+
public. This is field visibility, not fog-of-war entity hiding.
|
|
946
|
+
|
|
947
|
+
A collider transmits only geometry plus explicitly named public scalar fields:
|
|
948
|
+
`collider: {size: 'size', enabled: 'solid', fields: ['material']}`. Do not put
|
|
949
|
+
private fields in that list. Unchanged collision rows are inherited from the
|
|
950
|
+
snapshot baseline; keyframes and reconnects remain complete.
|
|
951
|
+
|
|
952
|
+
For verified history declare
|
|
953
|
+
`room.records: {key: 'server:career-v1', identity: 'matchKey', eligible: 'ready',
|
|
954
|
+
fields: {kills: 'earnedKills', damage: 'earnedDamage'}}`. The identity names a
|
|
955
|
+
shared text field that rules make unique for each round. The optional eligibility
|
|
956
|
+
names a player boolean field; result fields name numeric player fields. The
|
|
957
|
+
Worker associates the participant with its authenticated player ID and stores
|
|
958
|
+
`id`, `endedAt`, `place`, `won` and the declared totals when the round completes.
|
|
959
|
+
A durable outbox retries failed writes; duplicate round IDs are ignored. Read
|
|
960
|
+
with `createSaves().get(key)`. Browser writes, deletes and save resets cannot
|
|
961
|
+
change `server:` records. Keep guest/authentication setup active before playing.
|
|
962
|
+
|
|
963
|
+
### Opt-in exact terrain and baked navigation
|
|
964
|
+
|
|
965
|
+
Legacy three-argument rays keep their historical result and cost. Supplying ray
|
|
966
|
+
options enables the richer query policy, including hits at distance zero when
|
|
967
|
+
starting inside solids. Terrain changes are separate: set `terrain: 'exact'` in
|
|
968
|
+
ray options or the entity body declaration to use exact face/edge/vertex terrain
|
|
969
|
+
casts. Existing terrain behavior is retained when omitted. Test your map's
|
|
970
|
+
slopes, ledges and movement when opting in.
|
|
971
|
+
|
|
972
|
+
Declare static graphs once with `navigation: {walk: graph}` in `defineRules`, then
|
|
973
|
+
call `world.route('walk', from, to, {radius, height})`. Compilation validates and
|
|
974
|
+
copies the graph once; handlers pay for the live search, not graph validation.
|
|
975
|
+
At most eight named graphs with 8192 nodes total are accepted. Unknown names fail.
|
|
976
|
+
The array form remains available for occasional dynamic graphs.
|
|
977
|
+
|
|
978
|
+
`world.route(graph, from, to, {radius, height})` searches an immutable authored
|
|
979
|
+
walking graph. Nodes are `[x, y, z, neighbourIndices]`; endpoints are node indices.
|
|
980
|
+
The graph has at most 8192 nodes and 16 neighbours per node. Edges must already
|
|
981
|
+
encode static terrain walkability. The runtime checks live colliders, returns
|
|
982
|
+
all node indices of a route or an empty list, and charges the search to the
|
|
983
|
+
handler budget. It never substitutes a partial route at an expansion cap.
|
|
984
|
+
Replan when the goal changes and when live geometry changes. Bake invalid or
|
|
985
|
+
occupied terrain nodes out of the graph, and choose reachable endpoints.
|
|
986
|
+
`@homie-rocks/nav` is the richer mesh/crowd API for applications that own that
|
|
987
|
+
runtime; rules graphs avoid importing mutable navigation state into handlers.
|
|
988
|
+
|
|
989
|
+
`room.predict.idleHold: true` holds small visual reconciliation offsets while
|
|
990
|
+
input and body are stationary. Movement resumes smoothing. Large invalid poses
|
|
991
|
+
still snap to authority. Opt in only after measuring stop/reversal and delayed
|
|
992
|
+
collision behavior for the game's mover.
|
|
993
|
+
|
|
994
|
+
For multi-cast movement or scratch trajectory checks, `ctx.world.sweep(body, delta, {ground:false})` (also captured-scene `sweep`) moves and returns the same collision but leaves `body.grounded` unchanged. Use it only when your mover computes support explicitly. Omitting the option retains the existing support query and grounding behavior. This avoids paying for identical support checks after horizontal and upward probes.
|
package/lib/build.mjs
CHANGED
|
@@ -602,7 +602,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
602
602
|
// studio.json site.order: the catalogue's order is every listing's order.
|
|
603
603
|
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
604
604
|
// Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
|
|
605
|
-
const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds }]));
|
|
605
|
+
const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
|
|
606
606
|
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
|
|
607
607
|
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
608
608
|
const builds = {};
|
|
@@ -184,7 +184,7 @@ function play(c, companions, allow) {
|
|
|
184
184
|
const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
|
|
185
185
|
|
|
186
186
|
const movesToCheck = [];
|
|
187
|
-
const moved = (...args) => movesToCheck.push(args);
|
|
187
|
+
const moved = (...args) => { movesToCheck.push(args); if (fault && fault.includes(`${args[0]}.move:`) && !fault.includes('Movement input:')) fault += ` Movement input: ${JSON.stringify(args[2])}; before: ${JSON.stringify(args[3])}.`; };
|
|
188
188
|
let A = open({ observe, noted, moved, send });
|
|
189
189
|
for (const m of told()) A.frame(m);
|
|
190
190
|
/** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
|
|
@@ -236,8 +236,9 @@ function play(c, companions, allow) {
|
|
|
236
236
|
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
237
237
|
const steering = new Map();
|
|
238
238
|
let moveTick = 0;
|
|
239
|
+
let moveSelf;
|
|
239
240
|
let moveGeometry = c.map;
|
|
240
|
-
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, geometry: () => moveGeometry, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
|
|
241
|
+
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, geometry: () => moveGeometry, self: () => moveSelf, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
|
|
241
242
|
/**
|
|
242
243
|
* An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
|
|
243
244
|
* the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
|
|
@@ -249,7 +250,7 @@ function play(c, companions, allow) {
|
|
|
249
250
|
const e = w && L.P.unpackEntity(c.kinds, w, c.dims);
|
|
250
251
|
if (!e) return null;
|
|
251
252
|
G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
|
|
252
|
-
moveTick = t;
|
|
253
|
+
moveTick = t; moveSelf = b.id;
|
|
253
254
|
const body = L.P.stepMove(k.move, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: L.P.thawFields(k.motion, e.motion) }, Object.freeze(values), moveCtx.get(k.name), Math.max(1, Math.floor(budget / 4)), k.motion, c.dims,
|
|
254
255
|
(error) => { fault ??= `${G.file}:${G.line} ${k.name}.move: ${fix(diagnostic(String(error?.message ?? error)))} A player's own browser runs this move for its body.${when()}`; });
|
|
255
256
|
G.note = null;
|
|
@@ -345,9 +346,9 @@ function play(c, companions, allow) {
|
|
|
345
346
|
const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
|
|
346
347
|
A.tickNow();
|
|
347
348
|
units += A.core.stats.tickUnits;
|
|
348
|
-
for (const [name, tick, input, before, after, geometry] of movesToCheck.splice(0)) {
|
|
349
|
+
for (const [name, tick, input, before, after, geometry, id] of movesToCheck.splice(0)) {
|
|
349
350
|
const kind = c.kinds.find(k => k.name === name);
|
|
350
|
-
moveTick = tick; moveGeometry = geometry ?? c.map;
|
|
351
|
+
moveTick = tick; moveGeometry = geometry ?? c.map; moveSelf = id;
|
|
351
352
|
const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
|
|
352
353
|
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
353
354
|
units += result.used;
|
package/lib/rules-check.mjs
CHANGED
|
@@ -28,10 +28,10 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
|
|
|
28
28
|
|
|
29
29
|
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
30
|
export function stateHash(c) {
|
|
31
|
-
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save } = c;
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds } = c;
|
|
32
32
|
// Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
|
|
33
33
|
const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
|
|
34
|
-
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
34
|
+
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -66,10 +66,11 @@ export const REFUSED_NAMES = ['constructor', 'prototype', '__proto__', 'stack',
|
|
|
66
66
|
/** The names JavaScript calls by itself to turn an object into a number or a text: no function may sit under one. */
|
|
67
67
|
export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
|
|
68
68
|
/** What the host runtime's own objects offer: `world`, `ctx`, the round, the map (rules/core.ts) and `world.math` (rules/math.ts). */
|
|
69
|
-
export const WORLD_METHODS = ['ticks', 'random', 'send', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'spot', 'spots'];
|
|
69
|
+
export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots'];
|
|
70
70
|
export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
|
|
71
71
|
const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
|
|
72
72
|
export const MOVE_METHODS = ['support', 'overlaps'];
|
|
73
|
+
const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart']);
|
|
73
74
|
const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
|
|
74
75
|
const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
|
|
75
76
|
/** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
|
|
@@ -398,7 +399,7 @@ export function guardSource(code, { file = 'rules.js', map = null, linked = fals
|
|
|
398
399
|
const decl = path.parentPath.isVariableDeclarator() && path.parentPath.parentPath.isVariableDeclaration() && statementsOf(path.parentPath.parentPath);
|
|
399
400
|
for (const p of path.node.properties) {
|
|
400
401
|
if (t.isRestElement(p)) continue;
|
|
401
|
-
const risky = p.computed || CALLABLE.has(p.key.name ?? p.key.value);
|
|
402
|
+
const risky = p.computed || (CALLABLE.has(p.key.name ?? p.key.value) && !NEW_METHODS.has(p.key.name ?? p.key.value));
|
|
402
403
|
if (risky && !(decl && t.isIdentifier(p.value))) bad(p, `take "${p.computed ? 'a computed key' : p.key.name ?? p.key.value}" out with a plain \`const { … } = value\` declaration, or read it with a dot: a pattern here could take a method out of a value`);
|
|
403
404
|
}
|
|
404
405
|
},
|
|
@@ -457,7 +458,7 @@ export function guardSource(code, { file = 'rules.js', map = null, linked = fals
|
|
|
457
458
|
const checks = [];
|
|
458
459
|
for (const d of path.node.declarations) {
|
|
459
460
|
if (!t.isObjectPattern(d.id)) continue;
|
|
460
|
-
for (const p of d.id.properties) if (!t.isRestElement(p) && t.isIdentifier(p.value) && (p.computed || CALLABLE.has(p.key.name ?? p.key.value))) checks.push(t.expressionStatement(call('nf', [t.identifier(p.value.name), line(p)])));
|
|
461
|
+
for (const p of d.id.properties) if (!t.isRestElement(p) && t.isIdentifier(p.value) && (p.computed || (CALLABLE.has(p.key.name ?? p.key.value) && !NEW_METHODS.has(p.key.name ?? p.key.value)))) checks.push(t.expressionStatement(call('nf', [t.identifier(p.value.name), line(p)])));
|
|
461
462
|
}
|
|
462
463
|
if (checks.length) path.insertAfter(checks);
|
|
463
464
|
},
|
package/lib/rules-types.mjs
CHANGED
|
@@ -78,7 +78,7 @@ type W = World<${parameters}>;
|
|
|
78
78
|
type RW = World<${parameters}, true>;
|
|
79
79
|
type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
|
|
80
80
|
entities: ${entities}; move?: Moves;
|
|
81
|
-
room?: { rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number };
|
|
81
|
+
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number;
|
|
82
82
|
start?: (world: RW) => void;
|
|
83
83
|
join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => ${joins};
|
|
84
84
|
on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
|
|
@@ -2649,6 +2649,48 @@
|
|
|
2649
2649
|
}
|
|
2650
2650
|
}
|
|
2651
2651
|
}
|
|
2652
|
+
},
|
|
2653
|
+
"0.45.3": {
|
|
2654
|
+
"files": {
|
|
2655
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2656
|
+
"README.md": "684b3cca8001673c",
|
|
2657
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2658
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2659
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2660
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2661
|
+
"posts/README.md": "37841aae476bd540",
|
|
2662
|
+
"site/README.md": "732894273a32dc3f",
|
|
2663
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2664
|
+
},
|
|
2665
|
+
"sections": {
|
|
2666
|
+
"AGENTS.md": {
|
|
2667
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2668
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2669
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2670
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2671
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2672
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2673
|
+
"## Making games": "bb715272fd1a615b",
|
|
2674
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2675
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2676
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2677
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2678
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2679
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2680
|
+
"## Rules": "c48971c1437414c2",
|
|
2681
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2682
|
+
"## Beta": "9d02638207a26e73"
|
|
2683
|
+
}
|
|
2684
|
+
},
|
|
2685
|
+
"named": {
|
|
2686
|
+
"Homie Arcade": {
|
|
2687
|
+
"sections": {
|
|
2688
|
+
"AGENTS.md": {
|
|
2689
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2652
2694
|
}
|
|
2653
2695
|
}
|
|
2654
2696
|
}
|
package/netplay/netplay.ts
CHANGED
|
@@ -2395,6 +2395,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2395
2395
|
return;
|
|
2396
2396
|
}
|
|
2397
2397
|
case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
|
|
2398
|
+
case 'probe': if (typeof m.n === 'string') raw({t:'probeAck',n:m.n}); return;
|
|
2398
2399
|
case 'pong': return onPong(m);
|
|
2399
2400
|
case 'announce': {
|
|
2400
2401
|
if (typeof m['id'] !== 'string') return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.3",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/collision.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { castTerrain } from './terrain.ts';
|
|
2
|
+
import { castTerrain as legacyTerrain } from './terrain-legacy.ts';
|
|
2
3
|
import { nearbyMap } from './map-index.ts';
|
|
3
4
|
/** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
|
|
4
5
|
* Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
|
|
5
6
|
*/
|
|
6
|
-
import { charge } from './guard.ts';
|
|
7
|
+
import { charge, G } from './guard.ts';
|
|
7
8
|
import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
|
|
8
9
|
|
|
9
|
-
export interface BodyShape { shape: string; radius: number; height: number }
|
|
10
|
+
export interface BodyShape { shape: string; radius: number; height: number; terrain?: 'exact' }
|
|
10
11
|
export interface Solid { min: Vec3; max: Vec3; r: number }
|
|
11
12
|
export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
|
|
12
13
|
export interface Map3 {
|
|
@@ -14,7 +15,7 @@ export interface Map3 {
|
|
|
14
15
|
bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
|
|
15
16
|
heightTiles?: readonly MapHeightTile[];
|
|
16
17
|
spheres?: readonly MapCircle[];
|
|
17
|
-
capsules?: readonly (MapCircle & { height: number })[];
|
|
18
|
+
capsules?: readonly (MapCircle & { height: number; terrain?: 'exact' })[];
|
|
18
19
|
}
|
|
19
20
|
const EPS = 1e-7;
|
|
20
21
|
const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
|
|
@@ -33,8 +34,15 @@ function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
|
|
|
33
34
|
/** Continuous convex cast: each separating plane gives a lower bound on impact.
|
|
34
35
|
* Every iteration is charged, and a fixed cap bounds grazing contacts as well.
|
|
35
36
|
*/
|
|
36
|
-
export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
|
|
37
|
+
export function castSolid(a: Solid, d: Vec3, b: Solid, includeInside = false): Hit3 | null {
|
|
37
38
|
charge(30);
|
|
39
|
+
if (includeInside) {
|
|
40
|
+
const gap = separation(a, b, d, 0);
|
|
41
|
+
const sq = gap.x*gap.x + gap.y*gap.y + gap.z*gap.z;
|
|
42
|
+
const radius=a.r+b.r;
|
|
43
|
+
const inside = radius > 0 ? sq < radius*radius : a.max.x > b.min.x && a.min.x < b.max.x && a.max.y > b.min.y && a.min.y < b.max.y && a.max.z > b.min.z && a.min.z < b.max.z;
|
|
44
|
+
if (inside) {const length = Math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z)||1; return {t:0,nx:-d.x/length,ny:-d.y/length,nz:-d.z/length};}
|
|
45
|
+
}
|
|
38
46
|
let t = 0;
|
|
39
47
|
const r = a.r + b.r;
|
|
40
48
|
let normal = point(0, 0, 0);
|
|
@@ -94,12 +102,27 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
|
|
|
94
102
|
return best;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
|
|
105
|
+
let memoEpoch = -1;
|
|
106
|
+
let castMemo = new WeakMap<object, Map<string, Hit3 | null>>();
|
|
107
|
+
export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
|
|
108
|
+
// Exact casts can reuse identical immutable-scene queries within one handler.
|
|
109
|
+
// The epoch resets at every authority handler, including after restore.
|
|
110
|
+
if(body.terrain !== 'exact' || hits || !Number.isFinite(G.left)) return castMap3Uncached(map,p,d,body,hits,includeInside);
|
|
111
|
+
if(memoEpoch!==G.queryEpoch){memoEpoch=G.queryEpoch;castMemo=new WeakMap();}
|
|
112
|
+
charge(16);
|
|
113
|
+
let rows=castMemo.get(map);if(!rows){rows=new Map();castMemo.set(map,rows);}
|
|
114
|
+
const key=[p.x,p.y,p.z,d.x,d.y,d.z,body.shape,body.radius,body.height,includeInside].join(',');
|
|
115
|
+
if(rows.has(key))return rows.get(key)!;
|
|
116
|
+
const hit=castMap3Uncached(map,p,d,body,hits,includeInside);
|
|
117
|
+
if(rows.size<512)rows.set(key,hit);
|
|
118
|
+
return hit;
|
|
119
|
+
}
|
|
120
|
+
function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
|
|
98
121
|
map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
|
|
99
122
|
charge(24);
|
|
100
123
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
101
124
|
let best: Hit3 | null = null;
|
|
102
|
-
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1
|
|
125
|
+
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
|
|
103
126
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
104
127
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
105
128
|
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
@@ -107,12 +130,29 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
107
130
|
const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
|
|
108
131
|
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
109
132
|
}
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
const exactTerrain = body.terrain === 'exact';
|
|
134
|
+
const terrain = [...(map.heightTiles ?? [])];
|
|
135
|
+
if(exactTerrain && d.z<0){charge(terrain.length*Math.ceil(Math.log2(terrain.length+1)));terrain.sort((a,b)=>(b.at.z+Math.max(...b.heights))-(a.at.z+Math.max(...a.heights)));}
|
|
136
|
+
// Visit the nearest possible tile contacts first so later casts are shortened.
|
|
137
|
+
// All-hit queries retain authored ordering; legacy casts retain cost and behavior.
|
|
138
|
+
if(exactTerrain && (d.x||d.y) && !hits){
|
|
139
|
+
const first=(t:MapHeightTile)=>{let near=0;for(const k of ['x','y'] as const){if(d[k])near=Math.max(near,((d[k]>0?t.at[k]-r:t.at[k]+t.size[k]+r)-p[k])/d[k]);}return near;};
|
|
140
|
+
charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
|
|
141
|
+
}
|
|
142
|
+
for (const tile of terrain) {
|
|
143
|
+
if(exactTerrain && !hits && best?.t===0)break;
|
|
144
|
+
// Once a nearer surface is known, later solids only need to beat that time.
|
|
145
|
+
// This also bounds work on densely tessellated terrain behind the first hit.
|
|
146
|
+
const limit = exactTerrain && !hits && best ? best.t : 1;
|
|
147
|
+
const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
|
|
148
|
+
const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
|
|
149
|
+
if (hit) take({...hit, t: hit.t * limit});
|
|
150
|
+
}
|
|
151
|
+
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }, includeInside), (b as any).id);
|
|
112
152
|
// Legacy map circles are vertical columns in a 3D map.
|
|
113
|
-
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
|
|
114
|
-
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
|
|
115
|
-
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
|
|
153
|
+
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }, includeInside), (c as any).id);
|
|
154
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }, includeInside), (s as any).id);
|
|
155
|
+
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height }), includeInside), (c as any).id);
|
|
116
156
|
return best;
|
|
117
157
|
}
|
|
118
158
|
export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
|