lecodes-cli 0.18.0 → 0.18.2
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/README.md +1 -1
- package/dist/index.js +2013 -564
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/core/Aspect.ts +512 -255
- package/runtime/sdk/core/compWrite.ts +42 -0
- package/runtime/sdk/core/fields.ts +1 -1
- package/runtime/sdk/core/time.ts +81 -0
- package/runtime/sdk/g2/Camera2D.ts +8 -1
- package/runtime/sdk/g2/CharacterController2D.ts +253 -53
- package/runtime/sdk/g2/Node2D.ts +80 -10
- package/runtime/sdk/g2/OneWay2D.ts +66 -0
- package/runtime/sdk/g2/Physics2D.ts +240 -30
- package/runtime/sdk/g2/Scene2D.ts +33 -1
- package/runtime/sdk/g2/Shape2D.ts +218 -22
- package/runtime/sdk/g2/Trigger2D.ts +42 -12
- package/runtime/sdk/g2/groups2d.ts +106 -0
- package/runtime/sdk/g2/loop.ts +15 -4
- package/runtime/sdk/gl/Camera.ts +40 -1
- package/runtime/sdk/gl/CameraPlace.ts +52 -51
- package/runtime/sdk/gl/CharacterController.ts +184 -56
- package/runtime/sdk/gl/Gearbox.ts +212 -0
- package/runtime/sdk/gl/Geometry.ts +70 -9
- package/runtime/sdk/gl/Light.ts +64 -2
- package/runtime/sdk/gl/Lightmap.ts +179 -0
- package/runtime/sdk/gl/Material.ts +25 -0
- package/runtime/sdk/gl/Mesh.ts +6 -23
- package/runtime/sdk/gl/Model.ts +16 -2
- package/runtime/sdk/gl/Node.ts +119 -39
- package/runtime/sdk/gl/Physics.ts +75 -24
- package/runtime/sdk/gl/Scene.ts +161 -5
- package/runtime/sdk/gl/Shape.ts +42 -3
- package/runtime/sdk/gl/Trigger.ts +45 -50
- package/runtime/sdk/gl/Vehicle.ts +276 -322
- package/runtime/sdk/gl/Wheel.ts +240 -0
- package/runtime/sdk/gl/scenarios.ts +4 -30
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/inject.ts +186 -171
- package/runtime/sdk/runtime/device.ts +102 -1
- package/runtime/sdk/scene/defineScene.ts +108 -53
- package/runtime/sdk/scene/gizmos.ts +31 -11
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +0 -42
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -45
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -252
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -85
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -78
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderTargets.ts +0 -42
- package/runtime/sdk/compile/sourcemap.ts +0 -25
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// A one-way surface, as an aspect on a Node2D that already carries a Shape2D and a Physics2D body:
|
|
2
|
+
// solid from one side, passable from the other. The semisolid ledge of every platformer.
|
|
3
|
+
//
|
|
4
|
+
// const ledge = new Sprite({ texture: plank, size: [128, 8] })
|
|
5
|
+
// .aspect(Shape2D, {})
|
|
6
|
+
// .aspect(Physics2D, { motion: 'static' })
|
|
7
|
+
// .aspect(OneWay2D) // jump up through it, stand on top
|
|
8
|
+
//
|
|
9
|
+
// It is an ASPECT and not a flag on Shape2D on purpose: Shape2D is geometry, and which side of a
|
|
10
|
+
// surface is solid is behaviour. Keeping them apart is also what lets `normal` generalise the thing —
|
|
11
|
+
// a one-way WALL you can only walk through from the left is the same aspect turned sideways.
|
|
12
|
+
//
|
|
13
|
+
// Honoured by the character's mover AND, through a native pre-solve callback, by ordinary rigid
|
|
14
|
+
// bodies (a crate falls onto the ledge from above and passes up through it from below). Neither path
|
|
15
|
+
// costs any JS per frame.
|
|
16
|
+
//
|
|
17
|
+
// Dropping DOWN through one is the character's business, not the surface's — see
|
|
18
|
+
// `CharacterController2D.dropThrough`.
|
|
19
|
+
|
|
20
|
+
import { Aspect } from "../core/Aspect"
|
|
21
|
+
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
22
|
+
import { Physics2D } from "./Physics2D"
|
|
23
|
+
import type { Node2D } from "./Node2D"
|
|
24
|
+
|
|
25
|
+
export class OneWay2D extends Aspect<"oneWay", Node2D> {
|
|
26
|
+
static readonly aspect = "oneWay"
|
|
27
|
+
|
|
28
|
+
private _normal: Vec2Like = [ 0, 1 ]
|
|
29
|
+
private _arc = 90
|
|
30
|
+
private _enabled = true
|
|
31
|
+
|
|
32
|
+
/** Which side is solid. Default `[0, 1]` — solid from above, i.e. a floor you pass through from
|
|
33
|
+
* below. `[1, 0]` is a wall you can only enter from the left. */
|
|
34
|
+
get normal(): Vec2Like { return this._normal }
|
|
35
|
+
set normal(v: Vec2Like) { this._normal = v; this._push() }
|
|
36
|
+
|
|
37
|
+
/** How far off `normal` an approach still counts as coming from the solid side, in degrees.
|
|
38
|
+
* Default 90 — the whole half-plane, which is what a platform wants. */
|
|
39
|
+
get arc(): number { return this._arc }
|
|
40
|
+
set arc(v: number) { this._arc = v; this._push() }
|
|
41
|
+
|
|
42
|
+
/** Turn the one-way behaviour off, making the surface solid from both sides again. */
|
|
43
|
+
get enabled(): boolean { return this._enabled }
|
|
44
|
+
set enabled(v: boolean) { this._enabled = v; this._push() }
|
|
45
|
+
|
|
46
|
+
onAttach(): void {
|
|
47
|
+
if (!this.node.has(Physics2D)) {
|
|
48
|
+
throw new Error("OneWay2D needs a Physics2D body on the same node — add it first: node.aspect(Shape2D, {…}).aspect(Physics2D, { motion: 'static' }).aspect(OneWay2D)")
|
|
49
|
+
}
|
|
50
|
+
this._push()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onDetach(): void {
|
|
54
|
+
const id = this.node.get(Physics2D)?.id
|
|
55
|
+
if (id) _creator2d.physicsSetOneWay(id, 0, 1, 90, false)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onReconfigure(): void { this._push() }
|
|
59
|
+
|
|
60
|
+
private _push(): void {
|
|
61
|
+
const id = this.node.get(Physics2D)?.id
|
|
62
|
+
// via the public accessors on purpose: an accessor pair that is only ever an object-literal key
|
|
63
|
+
// gets tree-shaken away, and the option would silently do nothing (see Physics2D.onAttach).
|
|
64
|
+
if (id) _creator2d.physicsSetOneWay(id, cx(this.normal), cy(this.normal), this.arc, this.enabled)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -1,29 +1,42 @@
|
|
|
1
1
|
// A rigid body, as an aspect on a Node2D, backed by Box2D v3 in creator-2d. Mirrors the 3D Physics
|
|
2
2
|
// aspect. Requires a Shape2D aspect on the same node (its geometry becomes the body's solid fixture).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// ground.aspect(Shape2D, { box: [500, 8] }).aspect(Physics2D, { motion: 'static' })
|
|
5
|
+
// crate.aspect(Shape2D, {}).aspect(Physics2D, { mass: 4, friction: 0.7, bounce: 0.1 })
|
|
6
|
+
// crate.physics.applyImpulse([0, 400])
|
|
7
|
+
// crate.addEventListener('enter', (other, c) => spark(c.point, c.normal))
|
|
8
|
+
//
|
|
9
|
+
// Physics2D.configure() is OPTIONAL — the world is created on the first body with sane defaults, and
|
|
10
|
+
// calling configure later just changes gravity (it used to destroy and rebuild the world, orphaning
|
|
11
|
+
// every handle that was already out).
|
|
9
12
|
//
|
|
10
13
|
// For a `dynamic` body, physics OWNS the transform — don't set node.position per frame; drive it via
|
|
11
|
-
// velocity / applyImpulse.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
+
// velocity / applyImpulse. A `position` write is a TELEPORT that reaches the body (Node2D routes it),
|
|
15
|
+
// which is also how a kinematic body is placed. Reading node.position always works.
|
|
16
|
+
//
|
|
17
|
+
// Keep bodies on ROOT nodes: a body parented to a moving node does not follow it (see
|
|
18
|
+
// docs/2d-physics-plan.md §6).
|
|
14
19
|
|
|
15
20
|
import { Aspect } from "../core/Aspect"
|
|
16
21
|
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
17
22
|
import { node2dRegistry, type Node2D } from "./Node2D"
|
|
18
23
|
import { Shape2D } from "./Shape2D"
|
|
24
|
+
import {
|
|
25
|
+
PhysicsGroup2D, _addGroup, _watchGroupChanges, _unwatchGroupChanges,
|
|
26
|
+
categoryOf, solidMask, defaultGroup,
|
|
27
|
+
} from "./groups2d"
|
|
19
28
|
|
|
20
29
|
export type MotionType = "static" | "kinematic" | "dynamic"
|
|
21
30
|
const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
|
|
22
31
|
|
|
32
|
+
/** A neutral solid surface. Deliberately the same default as 3D `Physics.friction`. */
|
|
33
|
+
export const DEFAULT_FRICTION = 0.6
|
|
34
|
+
|
|
23
35
|
export interface PhysicsConfig {
|
|
24
36
|
/** Gravity in world units/s² (Y-up: down is negative). Default [0, -980]. */
|
|
25
37
|
gravity?: Vec2Like
|
|
26
|
-
/** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64.
|
|
38
|
+
/** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64.
|
|
39
|
+
* Only applies before the world exists (it is a global solver tolerance). */
|
|
27
40
|
pixelsPerMeter?: number
|
|
28
41
|
/** Solver sub-steps per fixed step. Default 4. */
|
|
29
42
|
subSteps?: number
|
|
@@ -43,22 +56,75 @@ const node2dById = (id: number): Node2D | null => (id ? node2dRegistry.get(id) ?
|
|
|
43
56
|
export class Physics2D extends Aspect<"physics", Node2D> {
|
|
44
57
|
static readonly aspect = "physics"
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
private _motion: MotionType = "dynamic"
|
|
60
|
+
/** Static (walls, floors), kinematic (script-driven platforms), or dynamic (fully simulated).
|
|
61
|
+
* Default "dynamic", and LIVE: freeze a crate into `'static'`, hand a ragdoll to `'dynamic'`. */
|
|
62
|
+
get motion(): MotionType { return this._motion }
|
|
63
|
+
set motion(m: MotionType) {
|
|
64
|
+
this._motion = m
|
|
65
|
+
this.node._physicsDriven = m !== "static"
|
|
66
|
+
if (this._h) _creator2d.physicsSetMotionType(this._h, MOTION[m])
|
|
67
|
+
}
|
|
51
68
|
/** Lock rotation (essential for platformer characters). */
|
|
52
69
|
fixedRotation = false
|
|
53
70
|
/** Continuous collision for fast-moving bodies (projectiles). */
|
|
54
71
|
bullet = false
|
|
55
72
|
|
|
73
|
+
private _mass = 0 // 0 = derive from the collider area
|
|
74
|
+
private _friction = DEFAULT_FRICTION
|
|
75
|
+
private _bounce = 0
|
|
56
76
|
private _gravityScale = 1
|
|
77
|
+
private _linearDamping = 0
|
|
78
|
+
private _angularDamping = 0
|
|
79
|
+
private _group: PhysicsGroup2D | undefined
|
|
80
|
+
private _repush = (): void => {
|
|
81
|
+
if (this._h) _creator2d.physicsSetFilter(this._h, categoryOf(this._group), solidMask(this._group))
|
|
82
|
+
}
|
|
57
83
|
private _h = 0 // native body id (0 = none / no physics support)
|
|
58
84
|
|
|
85
|
+
/** Which collision group this body belongs to (see Physics2D.addGroup). Live. */
|
|
86
|
+
get group(): PhysicsGroup2D | undefined { return this._group }
|
|
87
|
+
set group(g: PhysicsGroup2D | undefined) {
|
|
88
|
+
this._group = g
|
|
89
|
+
this.node.get(Shape2D)?._setFilter(categoryOf(g), solidMask(g))
|
|
90
|
+
this._repush()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Mass in world units. Omitted (or 0) = derived from the collider's AREA, so a big crate really is
|
|
94
|
+
* heavier than a small one. Setting it scales the shape-derived mass data, keeping the rotational
|
|
95
|
+
* inertia in the same ratio — a lopsided collider still tips the way its geometry says. */
|
|
96
|
+
get mass(): number { return this._h ? _creator2d.physicsGetMass(this._h) : this._mass }
|
|
97
|
+
set mass(v: number) { this._mass = v; if (this._h) _creator2d.physicsSetMass(this._h, v) }
|
|
98
|
+
|
|
99
|
+
/** Surface friction. 0 is frictionless, ~1 grippy; two bodies COMBINE as sqrt(a×b), so the lower
|
|
100
|
+
* value wins the argument. Default 0.6 — a neutral solid, matching 3D. */
|
|
101
|
+
get friction(): number { return this._friction }
|
|
102
|
+
set friction(v: number) { this._friction = v; this._pushMaterial() }
|
|
103
|
+
|
|
104
|
+
/** Bounciness, 0..1 — the fraction of the approach speed returned on impact. Two bodies combine as
|
|
105
|
+
* MAX (the bouncier one wins — the opposite of friction), and below ~64 units/s no bounce is
|
|
106
|
+
* applied at all, which is what stops a ball jittering on the floor forever. */
|
|
107
|
+
get bounce(): number { return this._bounce }
|
|
108
|
+
set bounce(v: number) { this._bounce = v; this._pushMaterial() }
|
|
109
|
+
|
|
110
|
+
// The live setters go to the BODY, but a later collider rebuild re-creates the fixture from the
|
|
111
|
+
// Shape2D — so the shape keeps its own copy of the material or a crouch would silently reset it.
|
|
112
|
+
private _pushMaterial(): void {
|
|
113
|
+
if (!this._h) return
|
|
114
|
+
_creator2d.physicsSetFriction(this._h, this._friction)
|
|
115
|
+
_creator2d.physicsSetBounce(this._h, this._bounce)
|
|
116
|
+
this.node.get(Shape2D)?._setMaterial(this._friction, this._bounce)
|
|
117
|
+
}
|
|
118
|
+
|
|
59
119
|
/** Native physics body id. 0 until attached, or if the build has no physics support. Mirrors 3D `Physics.id`. */
|
|
60
120
|
get id(): number { return this._h }
|
|
61
121
|
|
|
122
|
+
// NOTE — read every option through its PUBLIC accessor here, never the `_backing` field. chisel
|
|
123
|
+
// tree-shakes class members and keeps `_*` unconditionally, but it DROPS an accessor pair whose
|
|
124
|
+
// name no bundled code reads as a PROPERTY: an object-literal key (`aspect(Physics2D, { gravityScale: 0 })`)
|
|
125
|
+
// does NOT count. With the pair gone, Object.assign writes a plain own property, the backing field
|
|
126
|
+
// keeps its default, and the option silently does nothing — measured: a `gravityScale: 0` body fell.
|
|
127
|
+
// One `this.gravityScale` read in bundled SDK code keeps the whole pair alive.
|
|
62
128
|
onAttach(): void {
|
|
63
129
|
if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
|
|
64
130
|
const shape = this.node.get(Shape2D)
|
|
@@ -67,18 +133,47 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
67
133
|
}
|
|
68
134
|
this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
|
|
69
135
|
if (!this._h) return
|
|
70
|
-
shape._addTo(this._h,
|
|
136
|
+
shape._addTo(this._h, {
|
|
137
|
+
sensor: false, friction: this.friction, bounce: this.bounce,
|
|
138
|
+
category: categoryOf(this.group), mask: solidMask(this.group),
|
|
139
|
+
staticBody: this.motion === "static",
|
|
140
|
+
})
|
|
141
|
+
// Only a body that named a group cares when the topology changes.
|
|
142
|
+
if (this._group) _watchGroupChanges(this._repush)
|
|
71
143
|
// dynamic/kinematic bodies are moved by the native step → the node must re-sync its transform
|
|
72
144
|
// cache from native on read. Static bodies never move, so they keep the free cache.
|
|
73
145
|
if (this.motion !== "static") this.node._physicsDriven = true
|
|
146
|
+
// Route node.position / .rotation writes to the body (this is what replaced physics.moveTo).
|
|
147
|
+
this.node._xf = this._h
|
|
148
|
+
this.node._xfKind = 2
|
|
149
|
+
if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
|
|
74
150
|
if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
|
|
75
|
-
if (this.
|
|
151
|
+
if (this.gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
|
|
152
|
+
if (this.linearDamping !== 0) _creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
|
|
153
|
+
if (this.angularDamping !== 0) _creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
|
|
76
154
|
if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
|
|
155
|
+
// The listener may have been added before the body existed; contact events are per-body opt-in.
|
|
156
|
+
if (this.node._wantsContacts) _creator2d.physicsSetContactEvents(this._h, true)
|
|
77
157
|
}
|
|
78
158
|
|
|
79
159
|
onDetach(): void {
|
|
160
|
+
_unwatchGroupChanges(this._repush)
|
|
80
161
|
if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
|
|
81
162
|
this.node._physicsDriven = false
|
|
163
|
+
this.node._xf = 0
|
|
164
|
+
this.node._xfKind = 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Re-applying options (`node.aspect(Physics2D, {…})`) pushes the live ones through. */
|
|
168
|
+
onReconfigure(): void {
|
|
169
|
+
if (!this._h) return
|
|
170
|
+
this._pushMaterial()
|
|
171
|
+
_creator2d.physicsSetFixedRotation(this._h, this.fixedRotation)
|
|
172
|
+
_creator2d.physicsSetBullet(this._h, this.bullet)
|
|
173
|
+
_creator2d.physicsSetGravityScale(this._h, this.gravityScale)
|
|
174
|
+
_creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
|
|
175
|
+
_creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
|
|
176
|
+
if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
|
|
82
177
|
}
|
|
83
178
|
|
|
84
179
|
/** Linear velocity in world units/second (fresh Vec2 on read). */
|
|
@@ -89,30 +184,55 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
89
184
|
set velocity(v: Vec2Like) { if (this._h) _creator2d.physicsSetLinearVelocity(this._h, cx(v), cy(v)) }
|
|
90
185
|
|
|
91
186
|
/** Angular velocity in degrees/second. */
|
|
187
|
+
get angularVelocity(): number { return this._h ? _creator2d.physicsGetAngularVelocity(this._h) : 0 }
|
|
92
188
|
set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }
|
|
93
189
|
|
|
94
|
-
/** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled.
|
|
95
|
-
* time (as an option) or live. */
|
|
190
|
+
/** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled. */
|
|
96
191
|
get gravityScale(): number { return this._gravityScale }
|
|
97
192
|
set gravityScale(scale: number) { this._gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
|
|
98
|
-
|
|
193
|
+
|
|
194
|
+
get linearDamping(): number { return this._linearDamping }
|
|
195
|
+
set linearDamping(d: number) { this._linearDamping = d; if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
|
|
196
|
+
|
|
197
|
+
get angularDamping(): number { return this._angularDamping }
|
|
198
|
+
set angularDamping(d: number) { this._angularDamping = d; if (this._h) _creator2d.physicsSetAngularDamping(this._h, d) }
|
|
199
|
+
|
|
200
|
+
/** Pause this body — it stops simulating and stops colliding until re-enabled. */
|
|
99
201
|
set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }
|
|
100
202
|
|
|
101
|
-
/**
|
|
203
|
+
/** Box2D puts resting bodies to sleep; wake one to make it react to a change you made by hand. */
|
|
204
|
+
set awake(v: boolean) { if (this._h) _creator2d.physicsSetAwake(this._h, v) }
|
|
205
|
+
|
|
206
|
+
/** Apply an instantaneous impulse at the centre of mass (never spins the body) and wake it. */
|
|
102
207
|
applyImpulse(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyLinearImpulse(this._h, cx(v), cy(v)); return this }
|
|
208
|
+
/** Apply an impulse at a WORLD point — the lever arm becomes spin. Use this when the push has a real
|
|
209
|
+
* point of contact: a bullet's hit point, a blast pushing a prop at its base. */
|
|
210
|
+
applyImpulseAt(v: Vec2Like, worldPoint: Vec2Like): this {
|
|
211
|
+
if (this._h) _creator2d.physicsApplyImpulseAt(this._h, cx(v), cy(v), cx(worldPoint), cy(worldPoint))
|
|
212
|
+
return this
|
|
213
|
+
}
|
|
103
214
|
/** Apply a continuous force (world units) and wake the body. */
|
|
104
215
|
applyForce(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyForce(this._h, cx(v), cy(v)); return this }
|
|
105
|
-
/** Teleport / drive a kinematic (or dynamic) body to a world position (+ optional rotation degrees). */
|
|
106
|
-
moveTo(p: Vec2Like, deg = 0): this { if (this._h) _creator2d.physicsSetTransform(this._h, cx(p), cy(p), deg); return this }
|
|
107
216
|
|
|
108
217
|
// --- world (static) -------------------------------------------------------------------------
|
|
109
|
-
/**
|
|
218
|
+
/** Set the world's gravity / solver settings. OPTIONAL: the world is created on the first body with
|
|
219
|
+
* gravity [0, -980], 64 px/m and 4 sub-steps. Calling this later changes gravity LIVE and leaves
|
|
220
|
+
* bodies alone. */
|
|
110
221
|
static configure(config: PhysicsConfig = {}): void {
|
|
111
222
|
const g = config.gravity ?? [0, -980]
|
|
112
223
|
_creator2d.physicsConfigure(cx(g), cy(g), config.pixelsPerMeter ?? 64, config.subSteps ?? 4)
|
|
113
224
|
}
|
|
114
225
|
|
|
115
|
-
/**
|
|
226
|
+
/** World gravity, live. */
|
|
227
|
+
static get gravity(): Vec2 { return new Vec2(Physics2D._gravity) }
|
|
228
|
+
static set gravity(g: Vec2Like) {
|
|
229
|
+
Physics2D._gravity = [ cx(g), cy(g) ]
|
|
230
|
+
_creator2d.physicsConfigure(cx(g), cy(g), 0, 0)
|
|
231
|
+
}
|
|
232
|
+
private static _gravity: [number, number] = [ 0, -980 ]
|
|
233
|
+
|
|
234
|
+
/** Whether this build has physics support (CREATOR_2D_PHYSICS). NOTE: this reports the BUILD, not
|
|
235
|
+
* the world — there is nothing else to guard, since the world creates itself. */
|
|
116
236
|
static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
|
|
117
237
|
|
|
118
238
|
/** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
|
|
@@ -122,15 +242,105 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
122
242
|
static get interpolation(): boolean { return Physics2D._interpolation }
|
|
123
243
|
static set interpolation(on: boolean) { Physics2D._interpolation = on; _creator2d.setInterpolation(on) }
|
|
124
244
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Register a collision group. Groups are SUBTRACTIVE and SYMMETRIC (see groups2d.ts):
|
|
247
|
+
*
|
|
248
|
+
* const player = Physics2D.addGroup()
|
|
249
|
+
* const bullets = Physics2D.addGroup().ignoreSelf().ignore(player)
|
|
250
|
+
*/
|
|
251
|
+
static addGroup(label?: string): PhysicsGroup2D { return _addGroup(label) }
|
|
252
|
+
|
|
253
|
+
/** The implicit group of every body that never named one - name it to exclude "everything else". */
|
|
254
|
+
static get defaultGroup(): PhysicsGroup2D { return defaultGroup }
|
|
255
|
+
|
|
256
|
+
// --- queries (static) -----------------------------------------------------------------------
|
|
257
|
+
/** Closest body hit by the segment, or null. `node` resolves the hit entity. */
|
|
258
|
+
static raycast(from: Vec2Like, to: Vec2Like, q?: Query2D): RayHit | null {
|
|
259
|
+
const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to), maskOf(q), ignoreOf(q), ignoreCount(q))
|
|
128
260
|
if (o[0] < 0.5) return null
|
|
129
261
|
return { node: node2dById(o[1]), point: new Vec2(o[2], o[3]), normal: new Vec2(o[4], o[5]), fraction: o[6] }
|
|
130
262
|
}
|
|
131
263
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
264
|
+
/** Every body along the segment, NEAREST FIRST - a piercing bullet, a laser, "how many walls are
|
|
265
|
+
* between us". Capped at 32 hits. */
|
|
266
|
+
static raycastAll(from: Vec2Like, to: Vec2Like, q?: Query2D): RayHit[] {
|
|
267
|
+
const o = _creator2d.physicsRaycastAll(cx(from), cy(from), cx(to), cy(to), maskOf(q), ignoreOf(q), ignoreCount(q), MAX_HITS)
|
|
268
|
+
const hits: RayHit[] = []
|
|
269
|
+
for (let i = 0; i + 5 < o.length; i += 6) {
|
|
270
|
+
if (o[i] === 0) break
|
|
271
|
+
hits.push({ node: node2dById(o[i]), point: new Vec2(o[i + 1], o[i + 2]), normal: new Vec2(o[i + 3], o[i + 4]), fraction: o[i + 5] })
|
|
272
|
+
}
|
|
273
|
+
return hits
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Topmost body (solid or sensor) whose shape contains the world point, or null. "Topmost" means
|
|
277
|
+
* by DRAW order - layer, then z - so picking agrees with what the player sees. */
|
|
278
|
+
static overlapPoint(p: Vec2Like, q?: Query2D): Node2D | null {
|
|
279
|
+
return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p), maskOf(q), ignoreOf(q), ignoreCount(q)))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Everything within `radius` of a point - an explosion, an aura, an aggro range. */
|
|
283
|
+
static overlapCircle(centre: Vec2Like, radius: number, q?: Query2D): Node2D[] {
|
|
284
|
+
return overlapNodes([ cx(centre), cy(centre) ], radius, q)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Everything inside an axis-aligned box (centre + HALF-extents) - a room, a camera bound. */
|
|
288
|
+
static overlapBox(centre: Vec2Like, half: Vec2Like, q?: Query2D): Node2D[] {
|
|
289
|
+
const x = cx(centre), y = cy(centre), hw = cx(half), hh = cy(half)
|
|
290
|
+
return overlapNodes([ x - hw, y - hh, x + hw, y - hh, x + hw, y + hh, x - hw, y + hh ], 0, q)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Everything touching a capsule from-to of the given radius. A capsule is the shape a DIRECTED
|
|
295
|
+
* question has: a beam with width, a sword arc, "does my radius fit through this gap" - none of
|
|
296
|
+
* which an axis-aligned box can express.
|
|
297
|
+
*
|
|
298
|
+
* It is also the SWEPT test: a circle of radius r moving from A to B during a frame sweeps exactly
|
|
299
|
+
* this capsule, so a fast projectile checked this way cannot tunnel through a thin wall the way two
|
|
300
|
+
* separate position checks do.
|
|
301
|
+
*/
|
|
302
|
+
static overlapCapsule(from: Vec2Like, to: Vec2Like, radius: number, q?: Query2D): Node2D[] {
|
|
303
|
+
return overlapNodes([ cx(from), cy(from), cx(to), cy(to) ], radius, q)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Which groups a query may hit, and what to skip. */
|
|
308
|
+
export type Query2D = {
|
|
309
|
+
/** Only hit bodies in these groups. Omitted = everything. */
|
|
310
|
+
groups?: PhysicsGroup2D[]
|
|
311
|
+
/** Skip these nodes - the "don't hit myself" case a ray fired from inside its own body needs. */
|
|
312
|
+
ignore?: Node2D | Node2D[]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const MAX_HITS = 32
|
|
316
|
+
const emptyIgnore = new Float32Array(0)
|
|
317
|
+
let ignoreBuf = new Float32Array(8)
|
|
318
|
+
|
|
319
|
+
const maskOf = (q?: Query2D): number => {
|
|
320
|
+
if (!q?.groups || q.groups.length === 0) return 0 // 0 = everything, native-side
|
|
321
|
+
let m = 0
|
|
322
|
+
for (const g of q.groups) m |= g.category
|
|
323
|
+
return m >>> 0
|
|
324
|
+
}
|
|
325
|
+
const ignoreCount = (q?: Query2D): number =>
|
|
326
|
+
!q?.ignore ? 0 : Array.isArray(q.ignore) ? q.ignore.length : 1
|
|
327
|
+
const ignoreOf = (q?: Query2D): Float32Array => {
|
|
328
|
+
const n = ignoreCount(q)
|
|
329
|
+
if (n === 0) return emptyIgnore
|
|
330
|
+
if (ignoreBuf.length < n) ignoreBuf = new Float32Array(n)
|
|
331
|
+
const ig = q!.ignore!
|
|
332
|
+
if (Array.isArray(ig)) ig.forEach((node, i) => { ignoreBuf[i] = node.id })
|
|
333
|
+
else ignoreBuf[0] = (ig as Node2D).id
|
|
334
|
+
return ignoreBuf
|
|
335
|
+
}
|
|
336
|
+
const overlapNodes = (pts: number[], radius: number, q?: Query2D): Node2D[] => {
|
|
337
|
+
const o = _creator2d.physicsOverlap(new Float32Array(pts), pts.length / 2, radius,
|
|
338
|
+
maskOf(q), ignoreOf(q), ignoreCount(q), MAX_HITS)
|
|
339
|
+
const out: Node2D[] = []
|
|
340
|
+
for (let i = 0; i < o.length; i++) {
|
|
341
|
+
if (o[i] === 0) break
|
|
342
|
+
const n = node2dById(o[i])
|
|
343
|
+
if (n) out.push(n)
|
|
135
344
|
}
|
|
345
|
+
return out
|
|
136
346
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.
|
|
3
3
|
|
|
4
4
|
import { Color, type ColorInput } from "../core/color"
|
|
5
|
-
import { _installAspectFrames } from "../core/Aspect"
|
|
5
|
+
import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem, _detachAllSystems, type Aspect, type AspectCtor, type FieldOf, type TargetOf } from "../core/Aspect"
|
|
6
6
|
import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
|
|
7
7
|
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
8
8
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
@@ -24,6 +24,8 @@ const installRenderSyncedFrames = (): void => {
|
|
|
24
24
|
_creator2d.setLateUpdate(late)
|
|
25
25
|
return true
|
|
26
26
|
})
|
|
27
|
+
// Time.scale / Time.paused reach the engine's physics + animation clocks (feature-detected).
|
|
28
|
+
_installTimeScale((scale) => { _creator2d.setTimeScale?.(scale) })
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export type Scene2DOptions = {
|
|
@@ -112,6 +114,35 @@ export class Scene2D implements Presentable {
|
|
|
112
114
|
return this
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
// ---- systems: aspects of the scene (core/Aspect.ts `System<'x', Scene2D>`) ----
|
|
118
|
+
/** @internal class → instance, the authoritative store (named accessors mirror this). */
|
|
119
|
+
readonly _systems = new Map<Function, Aspect<any, any, any>>()
|
|
120
|
+
|
|
121
|
+
/** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
|
|
122
|
+
* as now-having it (`scene.system(Hud).hud`). A 3D `System<'x'>` (target Scene) is rejected here. */
|
|
123
|
+
// `this: Self` (not `Self & Scene2D`): with the intersection TS infers Self = Scene2D and a chain
|
|
124
|
+
// `scene.system(A).system(B)` loses A's accessor from the result type.
|
|
125
|
+
system<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
|
|
126
|
+
this: Self,
|
|
127
|
+
ctor: AspectCtor<A>,
|
|
128
|
+
opts?: Partial<A>,
|
|
129
|
+
): Self & FieldOf<A> {
|
|
130
|
+
return _attachSystem(this as unknown as Scene2D, ctor, opts) as unknown as Self & FieldOf<A>
|
|
131
|
+
}
|
|
132
|
+
/** Safe access — undefined if the system isn't attached. */
|
|
133
|
+
get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
|
|
134
|
+
return this._systems.get(ctor) as A | undefined
|
|
135
|
+
}
|
|
136
|
+
/** Existence check AND type guard: inside `if (scene.has(Hud))`, `scene.hud` is present. */
|
|
137
|
+
has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
|
|
138
|
+
return this._systems.has(ctor)
|
|
139
|
+
}
|
|
140
|
+
/** Detach a system (runs its onDetach). Chainable. */
|
|
141
|
+
removeSystem<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
|
|
142
|
+
_detachSystem(this, ctor)
|
|
143
|
+
return this
|
|
144
|
+
}
|
|
145
|
+
|
|
115
146
|
// ---- Presentable (docs/navigation-presentable-plan.md) ----
|
|
116
147
|
|
|
117
148
|
/** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
|
|
@@ -171,6 +202,7 @@ export class Scene2D implements Presentable {
|
|
|
171
202
|
return this
|
|
172
203
|
}
|
|
173
204
|
destroy(): void {
|
|
205
|
+
_detachAllSystems(this) // systems go first (last-attached first), while the scene still exists
|
|
174
206
|
this.close()
|
|
175
207
|
_creator2d.destroyScene(this.id)
|
|
176
208
|
}
|