@dcl-regenesislabs/web-editor 0.3.20 → 0.3.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcl-regenesislabs/web-editor",
3
- "version": "0.3.20",
3
+ "version": "0.3.21",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Decentraland scene editor for the web explorer: editor.js plus the files it loads beside it. Served by version, not installed.",
6
6
  "repository": {
@@ -0,0 +1,81 @@
1
+ ---
2
+ prefab: camera-rig
3
+ claims-globals: __dclCameraRig_v1
4
+ ---
5
+
6
+ # Camera Rig — AI guide
7
+
8
+ A fixed game camera: while the player stands inside the rig's area (or all the
9
+ time), their view switches to a camera looking down at the area from a fixed
10
+ angle, far enough back that the whole area fits their screen, a phone's wide
11
+ canvas included. Leaving the area gives the default camera back.
12
+
13
+ The project copy is normally custom/camera_rig/ — check what is on disk, a
14
+ second copy is custom/camera_rig_2/.
15
+
16
+ ## When to use
17
+
18
+ Any game played on a board, arena or kitchen the player should see whole:
19
+ Overcooked-style co-op, party games, top-down shooters, puzzle rooms, a
20
+ mini-golf hole. The default third-person camera trails the avatar and hides
21
+ most of such a play area; place one Camera Rig over it instead of writing
22
+ VirtualCamera code.
23
+
24
+ ## API
25
+
26
+ The rig's own Transform is the setup — no code, no names to wire:
27
+ - position: the point the camera looks at. Place it at the centre of the play
28
+ area, at floor height.
29
+ - scale: the area in metres (default 12 x 4 x 12). It is both where the camera
30
+ takes over and what it frames. Set it with the placePrefab `scale` to the
31
+ play area's size, leaving a metre of floor around the edges.
32
+ - rotation: which way is up on screen (the rig's forward, +Z). The assistant
33
+ cannot rotate it; the default faces north (+Z). If the user wants another
34
+ side, tell them to turn the rig with the rotate gizmo.
35
+
36
+ Params, set in the placePrefab request:
37
+ - mode: 'isometric' (default; angled, the Overcooked look) | 'top-down'
38
+ (straight down, a board-game view) | 'follow' (the same fixed angle, but
39
+ the camera tracks the player, for areas bigger than one screen).
40
+ - activeWhen: 'inside this area' (default) | 'always' (the whole scene, as
41
+ long as the player is in it).
42
+ - angle: degrees the camera looks down from the horizon for isometric and
43
+ follow, 10 to 88 (default 55). Top-down ignores it.
44
+ - distance: metres from the camera to the look point. 0 (default) computes it
45
+ so the whole area fits on screen; set it only to force a closer shot.
46
+ - fitSize: metres of ground the shot must show across and deep. 0 (default)
47
+ uses the rig's scale; in follow mode 0 means 10 m around the player.
48
+ - transitionSeconds: how long the view glides into the rig's camera (default
49
+ 0.6, 0 cuts).
50
+
51
+ Framing uses the player's real canvas aspect (a 1600x720 phone landscape is
52
+ handled) plus a margin so the phone's touch buttons do not cover the edges.
53
+ The framing math is in the module header of
54
+ src/scripts/runtime/pure/cameraRig.ts.
55
+
56
+ ## Movement and players
57
+
58
+ Movement keys keep working; the rig never freezes the avatar. Whether "forward"
59
+ walks toward the top of the screen under a fixed camera depends on the
60
+ explorer and is NOT verified yet: if the user reports inverted or sideways
61
+ controls, say so plainly instead of patching it with InputModifier. Each player
62
+ gets their own view: the rig only reacts to the player at that screen.
63
+
64
+ ## Do / Don't
65
+
66
+ - DO place exactly one rig per play area. Overlapping rigs are allowed: the
67
+ one entered last wins, and leaving it falls back to the one still around the
68
+ player.
69
+ - DON'T also set MainCamera or create a VirtualCamera in a script for the same
70
+ area; the rig owns the view while the player is inside. A script's own
71
+ cutscene camera is left alone when the rig lets go.
72
+ - DON'T parent the rig to a scaled object: its scale is the area.
73
+ - DON'T use 'follow' for a room that fits one screen; 'isometric' reads better.
74
+
75
+ ## Example
76
+
77
+ "A kitchen game seen from above": the kitchen floor is 14 x 10 m centred at
78
+ (16, 0, 16). placePrefab camera-rig named "Kitchen Camera" at position
79
+ (16, 0, 16) with scale (16, 4, 12), params mode: 'isometric', angle: 55,
80
+ activeWhen: 'inside this area'. Every cook who walks into the kitchen sees all
81
+ the stations at once; walking out restores the normal camera.
@@ -0,0 +1,52 @@
1
+ {
2
+ "version": 1,
3
+ "components": [
4
+ {
5
+ "name": "core-schema::Name",
6
+ "data": {
7
+ "0": {
8
+ "json": {
9
+ "value": "Camera Rig"
10
+ }
11
+ }
12
+ }
13
+ },
14
+ {
15
+ "name": "core::Transform",
16
+ "data": {
17
+ "0": {
18
+ "json": {
19
+ "scale": { "x": 12, "y": 4, "z": 12 }
20
+ }
21
+ }
22
+ }
23
+ },
24
+ {
25
+ "name": "core::TriggerArea",
26
+ "data": {
27
+ "0": {
28
+ "json": {
29
+ "mesh": 0,
30
+ "collisionMask": 8
31
+ }
32
+ }
33
+ }
34
+ },
35
+ {
36
+ "name": "asset-packs::Script",
37
+ "data": {
38
+ "0": {
39
+ "json": {
40
+ "value": [
41
+ {
42
+ "path": "{assetPath}/scripts/camera-rig.ts",
43
+ "priority": 0,
44
+ "layout": "{\"params\":{},\"actions\":[]}"
45
+ }
46
+ ]
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ]
52
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "7ea892f3-168f-40fb-8338-d9c91c23708e",
3
+ "name": "Camera Rig",
4
+ "description": "A fixed game camera — top-down, isometric or following the player — that takes over while a player is inside its area and frames the whole area on any screen, phones included.",
5
+ "category": "custom",
6
+ "tags": [
7
+ "camera",
8
+ "top-down",
9
+ "isometric",
10
+ "view"
11
+ ],
12
+ "version": "1.0.0",
13
+ "changelog": [
14
+ {
15
+ "version": "1.0.0",
16
+ "notes": "Initial release: a top-down, isometric or follow camera that takes over while a player is inside its area (or all the time), fits the whole area on desktop and phone screens, and hands the default camera back on the way out."
17
+ }
18
+ ],
19
+ "origin": {
20
+ "source": "builtin"
21
+ }
22
+ }
@@ -0,0 +1,199 @@
1
+ // A fixed game camera. While this player stands inside the rig's area (or all
2
+ // the time, with 'always'), their view switches to a camera that looks at the
3
+ // rig from above along the rig's heading, far enough back that the whole area
4
+ // fits on their screen — a phone's wide canvas included. Leaving the area hands
5
+ // the default camera back.
6
+ //
7
+ // The rig's own Transform is the whole authoring surface: its position is where
8
+ // the camera looks, its rotation is which way is up on screen, and its scale is
9
+ // the area in metres — the volume the editor draws while building, and the
10
+ // TriggerArea that decides "inside" while playing.
11
+ //
12
+ // The camera itself is a separate root entity: a VirtualCamera inherits its
13
+ // entity's scale, and the rig's scale is the area, not something to film with.
14
+ import {
15
+ MainCamera,
16
+ Transform,
17
+ TriggerArea,
18
+ UiCanvasInformation,
19
+ VirtualCamera,
20
+ engine,
21
+ triggerAreaEventsSystem,
22
+ type Entity
23
+ } from '@dcl/sdk/ecs'
24
+ import { isServer } from '@dcl/sdk/network'
25
+ import { isMobile } from '@dcl/sdk/platform'
26
+ import { localPlayerPosition } from '~runtime/playerPositions'
27
+ import { entityWorld } from '~runtime/sceneNames'
28
+ import { Membership } from '~runtime/pure/membership'
29
+ import {
30
+ CAMERA_FOV_DEG,
31
+ aspectFor,
32
+ cameraOnTop,
33
+ cameraPose,
34
+ claimCamera,
35
+ framingDistance,
36
+ pitchFor,
37
+ releaseCamera,
38
+ yawOf,
39
+ type CameraPose,
40
+ type RigClaims
41
+ } from '~runtime/pure/cameraRig'
42
+ import type { Vec3 } from '~runtime/pure/worldTransform'
43
+
44
+ // Collision layers by value: the SDK renamed CL_RESERVED2 to CL_MAIN_PLAYER, and
45
+ // this script compiles against whatever pin the creator's project carries. The
46
+ // local avatar alone carries 8, so the area reacts to the player at this screen.
47
+ const CL_MAIN_PLAYER = 8
48
+ const SPHERE_MESH = 1
49
+ // A player on the boundary crosses it every few frames; without this the view
50
+ // would snap back and forth.
51
+ const EXIT_GRACE_S = 0.3
52
+ const FOLLOW_EYE_M = 1
53
+ const MOVED_EPSILON = 1e-3
54
+ // Several rigs in one scene agree on who owns the screen through this key.
55
+ const CLAIMS_KEY = '__dclCameraRig_v1'
56
+
57
+ export class CameraRig {
58
+ private camera: Entity | null = null
59
+ private members = new Membership<Entity>()
60
+ private pose: CameraPose | null = null
61
+
62
+ constructor(
63
+ public src: string,
64
+ public entity: Entity,
65
+ /** How the camera looks at the area */
66
+ public mode: 'top-down' | 'isometric' | 'follow' = 'isometric',
67
+ /** When the camera takes over */
68
+ public activeWhen: 'inside this area' | 'always' = 'inside this area',
69
+ /** Degrees the camera looks down from the horizon, for isometric and follow. Top-down looks straight down. */
70
+ public angle: number = 55,
71
+ /** Metres from the camera to what it looks at. 0 fits the whole area on screen. */
72
+ public distance: number = 0,
73
+ /** Metres of ground the shot must show across and deep. 0 uses this rig's own area (its scale); follow uses 10. */
74
+ public fitSize: number = 0,
75
+ /** Seconds the view takes to glide into this camera. 0 cuts straight to it. */
76
+ public transitionSeconds: number = 0.6
77
+ ) {}
78
+
79
+ start(): void {
80
+ // The camera is one player's screen; a Multiplayer Server has none.
81
+ if (isServer()) return
82
+ const camera = engine.addEntity()
83
+ Transform.create(camera, { position: Transform.getOrNull(this.entity)?.position ?? { x: 0, y: 0, z: 0 } })
84
+ // Built as a value, not a literal, so an older SDK pin without `fov` still
85
+ // type-checks; its encoder drops the field and the explorer's default is 60.
86
+ const settings = {
87
+ defaultTransition: {
88
+ transitionMode: { $case: 'time' as const, time: Math.max(0, this.transitionSeconds) }
89
+ },
90
+ fov: CAMERA_FOV_DEG
91
+ }
92
+ VirtualCamera.create(camera, settings)
93
+ this.camera = camera
94
+
95
+ if (this.activeWhen === 'always') {
96
+ TriggerArea.deleteFrom(this.entity)
97
+ return
98
+ }
99
+ if (TriggerArea.getOrNull(this.entity)?.mesh === SPHERE_MESH) {
100
+ TriggerArea.setSphere(this.entity, CL_MAIN_PLAYER)
101
+ } else {
102
+ TriggerArea.setBox(this.entity, CL_MAIN_PLAYER)
103
+ }
104
+ // Stay as well as enter: a player who spawns inside the area never crosses
105
+ // into it, and the engine's enter fires only on the crossing.
106
+ triggerAreaEventsSystem.onTriggerEnter(this.entity, (result) => this.seen(result.trigger?.entity))
107
+ triggerAreaEventsSystem.onTriggerStay(this.entity, (result) => this.seen(result.trigger?.entity))
108
+ triggerAreaEventsSystem.onTriggerExit(this.entity, (result) => this.gone(result.trigger?.entity))
109
+ }
110
+
111
+ update(dt: number): void {
112
+ const camera = this.camera
113
+ if (camera === null) return
114
+ this.members.tick(dt, EXIT_GRACE_S)
115
+ const active = this.activeWhen === 'always' || this.members.size > 0
116
+ if (active) this.aim(camera)
117
+ const claims = rigClaims()
118
+ if (active) claimCamera(claims, camera)
119
+ else releaseCamera(claims, camera)
120
+ applyClaims(claims)
121
+ }
122
+
123
+ private aim(camera: Entity): void {
124
+ const rig = entityWorld(this.entity)
125
+ if (rig === null) return
126
+ let target: Vec3 = rig.position
127
+ if (this.mode === 'follow') {
128
+ const feet = localPlayerPosition()
129
+ if (feet === null && this.pose !== null) return
130
+ if (feet !== null) target = { x: feet.x, y: feet.y + FOLLOW_EYE_M, z: feet.z }
131
+ }
132
+ const mobile = isMobile()
133
+ const canvas = UiCanvasInformation.getOrNull(engine.RootEntity)
134
+ const pitchDeg = pitchFor(this.mode, this.angle)
135
+ const distance = framingDistance({
136
+ mode: this.mode,
137
+ distance: this.distance,
138
+ fitSize: this.fitSize,
139
+ area: rig.scale,
140
+ pitchDeg,
141
+ aspect: aspectFor(canvas, mobile),
142
+ mobile
143
+ })
144
+ const pose = cameraPose(target, yawOf(rig.rotation), pitchDeg, distance)
145
+ if (this.pose !== null && samePose(this.pose, pose)) return
146
+ this.pose = pose
147
+ Transform.createOrReplace(camera, { position: pose.position, rotation: pose.rotation })
148
+ }
149
+
150
+ // result.trigger.entity is the avatar that moved; the area is triggeredEntity.
151
+ private seen(raw?: number): void {
152
+ const who = toEntity(raw)
153
+ if (who !== undefined) this.members.touch(who)
154
+ }
155
+
156
+ private gone(raw?: number): void {
157
+ const who = toEntity(raw)
158
+ if (who !== undefined) this.members.drop(who)
159
+ }
160
+ }
161
+
162
+ function toEntity(raw?: number): Entity | undefined {
163
+ return raw === undefined || raw === 0 ? undefined : (raw as Entity)
164
+ }
165
+
166
+ function samePose(a: CameraPose, b: CameraPose): boolean {
167
+ const p = Math.abs(a.position.x - b.position.x) + Math.abs(a.position.y - b.position.y) + Math.abs(a.position.z - b.position.z)
168
+ const r =
169
+ Math.abs(a.rotation.x - b.rotation.x) +
170
+ Math.abs(a.rotation.y - b.rotation.y) +
171
+ Math.abs(a.rotation.z - b.rotation.z) +
172
+ Math.abs(a.rotation.w - b.rotation.w)
173
+ return p < MOVED_EPSILON && r < MOVED_EPSILON
174
+ }
175
+
176
+ function isClaims(value: unknown): value is RigClaims<Entity> {
177
+ return typeof value === 'object' && value !== null && Array.isArray((value as { order?: unknown }).order)
178
+ }
179
+
180
+ function rigClaims(): RigClaims<Entity> {
181
+ const globals = globalThis as unknown as Record<string, unknown>
182
+ const current = globals[CLAIMS_KEY]
183
+ if (isClaims(current)) return current
184
+ const created: RigClaims<Entity> = { order: [], applied: null }
185
+ globals[CLAIMS_KEY] = created
186
+ return created
187
+ }
188
+
189
+ function applyClaims(claims: RigClaims<Entity>): void {
190
+ const wanted = cameraOnTop(claims)
191
+ if (wanted === claims.applied) return
192
+ if (wanted !== null) {
193
+ MainCamera.createOrReplace(engine.CameraEntity, { virtualCameraEntity: wanted })
194
+ } else if (MainCamera.getOrNull(engine.CameraEntity)?.virtualCameraEntity === claims.applied) {
195
+ // Only undo our own write: a cutscene camera a script set meanwhile stays.
196
+ MainCamera.createOrReplace(engine.CameraEntity, {})
197
+ }
198
+ claims.applied = wanted
199
+ }
Binary file