@dcl-regenesislabs/web-editor 0.1.1-37509646646.commit-126b34d → 0.1.1-38415884993.commit-9d6277f

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.
Files changed (46) hide show
  1. package/editor.js +1231 -464
  2. package/package.json +2 -2
  3. package/prefabs/announcer/ai.md +9 -2
  4. package/prefabs/announcer/data.json +6 -7
  5. package/prefabs/announcer/scripts/announcer.tsx +16 -37
  6. package/prefabs/catalog.json +1 -1
  7. package/prefabs/game-flow/ai.md +6 -0
  8. package/prefabs/game-flow/data.json +7 -3
  9. package/prefabs/game-flow/scripts/game-flow.ts +22 -5
  10. package/prefabs/health-respawn/ai.md +25 -2
  11. package/prefabs/health-respawn/data.json +8 -1
  12. package/prefabs/health-respawn/scripts/health-respawn.ts +52 -20
  13. package/prefabs/health-respawn/scripts/health.ts +21 -0
  14. package/prefabs/health-respawn/scripts/pure/life.ts +59 -0
  15. package/prefabs/leaderboard/ai.md +27 -9
  16. package/prefabs/leaderboard/composite.json +2 -2
  17. package/prefabs/leaderboard/data.json +14 -7
  18. package/prefabs/leaderboard/scripts/leaderboard-hud.tsx +28 -0
  19. package/prefabs/leaderboard/scripts/leaderboard.ts +41 -5
  20. package/prefabs/leaderboard/scripts/pure/board.ts +10 -3
  21. package/prefabs/run-progress/ai.md +101 -0
  22. package/prefabs/run-progress/composite.json +9 -0
  23. package/prefabs/run-progress/data.json +43 -0
  24. package/prefabs/run-progress/scripts/moving-platform.ts +28 -0
  25. package/prefabs/run-progress/scripts/run-progress.tsx +170 -0
  26. package/prefabs/run-progress/thumbnail.png +0 -0
  27. package/prefabs/server-clock/ai.md +10 -7
  28. package/prefabs/server-clock/data.json +5 -1
  29. package/prefabs/server-clock/scripts/server-clock.ts +7 -6
  30. package/prefabs/trigger-zone/ai.md +13 -7
  31. package/prefabs/trigger-zone/data.json +2 -1
  32. package/prefabs/ui-theme/ai.md +30 -0
  33. package/prefabs/ui-theme/composite.json +31 -0
  34. package/prefabs/ui-theme/data.json +24 -0
  35. package/prefabs/ui-theme/scripts/ui-theme.ts +16 -0
  36. package/prefabs/ui-theme/thumbnail.png +0 -0
  37. package/scene/about +1 -1
  38. package/scene/{bafkreidzfny4im7pqpzuedreg2q5e6pd3vokz7h2py5b6zjxn3er6h4f5i → bafkreifyskofqvjvp22m3eaxz2mpwibp7j4wtimdj5bxtqsd7onkbnjlsa} +1 -1
  39. package/scene/bafybeiblcndgruoz3ewqrheei4moyeoehankul4yd43co33djksumfd3se +90 -0
  40. package/three/worker.js +3832 -0
  41. package/web-build/index.js +6 -3
  42. package/web-build/sdk-snapshot.json +1 -1
  43. package/web-build/worker.js +29 -26
  44. package/dcl-regenesislabs-web-editor-0.1.1-37509646646.commit-126b34d.tgz +0 -0
  45. package/prefabs/server-clock/scripts/clock-hud.tsx +0 -55
  46. package/scene/bafybeihw3xlbff4sf5sc3kvmlmlwf2visnty5lfymawlunzt5vrkp5fd7u +0 -90
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "44374100-12fb-481f-90ce-2a4d81949084",
3
3
  "name": "Game Flow",
4
- "description": "Lobby, countdown, rounds, winners — runs your game from start to end. Works with: Leaderboard, Announcer.",
4
+ "description": "Lobby, countdown, rounds, winners \u2014 runs your game from start to end. Works with: Leaderboard, Announcer.",
5
5
  "category": "custom",
6
6
  "group": "Multiplayer Server",
7
7
  "tags": [
@@ -14,11 +14,15 @@
14
14
  "winners"
15
15
  ],
16
16
  "requiresSdk": "auth-server",
17
- "version": "1.0.0",
17
+ "version": "1.1.0",
18
18
  "changelog": [
19
19
  {
20
20
  "version": "1.0.0",
21
- "notes": "Initial release: lobby → round → winners on game.round, a countdown every client derives from one shared deadline, a winners line read from the board key a Leaderboard shows, and a \"script\" mode where your own game.newRound() ends the round and the round length is only a ceiling."
21
+ "notes": "Initial release: lobby \u2192 round \u2192 winners on game.round, a countdown every client derives from one shared deadline, a winners line read from the board key a Leaderboard shows, and a \"script\" mode where your own game.newRound() ends the round and the round length is only a ceiling."
22
+ },
23
+ {
24
+ "version": "1.1.0",
25
+ "notes": "Optional themed round timer and lobby countdown, configured in the inspector."
22
26
  }
23
27
  ],
24
28
  "origin": {
@@ -11,9 +11,10 @@
11
11
  // rather than fighting it, and in that mode the round length is only a ceiling —
12
12
  // when the ceiling fires it goes out through game.newRound() too, so a round
13
13
  // never ends down two different paths. See ai.md in this folder.
14
- import { TextShape, type Entity } from '@dcl/sdk/ecs'
14
+ import { TextShape, VisibilityComponent, type Entity } from '@dcl/sdk/ecs'
15
15
  import { isServer } from '@dcl/sdk/network'
16
16
  import { game } from '~runtime/game'
17
+ import { mountStatusHud } from '~runtime/statusHud'
17
18
  import {
18
19
  advanceFlow,
19
20
  asFlowFact,
@@ -61,7 +62,10 @@ export class GameFlow {
61
62
  /** Who ends a round: this clock, or your own script calling game.newRound(). */
62
63
  public endsWhen: 'timer' | 'script' = 'timer',
63
64
  /** The game.state key the winners come from — the key a Leaderboard shows. */
64
- public boardKey: string = 'leaderboard'
65
+ public boardKey: string = 'leaderboard',
66
+ public display: '3D text' | '2D UI' = '3D text',
67
+ /** For "2D UI": where the round timer sits on screen. */
68
+ public position: 'top' | 'top left' | 'top right' | 'bottom' = 'top'
65
69
  ) {}
66
70
 
67
71
  private get config(): FlowConfig {
@@ -97,11 +101,20 @@ export class GameFlow {
97
101
  this.publish()
98
102
  })
99
103
  game.every(TICK_S, () => this.tick())
104
+ return
105
+ }
106
+ if (this.display === '2D UI') {
107
+ VisibilityComponent.createOrReplace(this.entity, { visible: false })
108
+ TextShape.deleteFrom(this.entity)
109
+ mountStatusHud(this.entity, this.position, () => {
110
+ const [label, value = '', detail] = this.text().split('\n')
111
+ return { label, value, detail }
112
+ })
100
113
  }
101
114
  }
102
115
 
103
116
  update(dt: number): void {
104
- if (isServer()) { return }
117
+ if (isServer() || this.display === '2D UI') { return }
105
118
  this.accum += dt
106
119
  if (this.accum < PAINT_S) return
107
120
  this.accum = 0
@@ -152,9 +165,13 @@ export class GameFlow {
152
165
  game.setState({ [FLOW_KEY]: fact })
153
166
  }
154
167
 
155
- private paint(): void {
168
+ private text(): string {
156
169
  const fact = asFlowFact(game.state[FLOW_KEY])
157
- const text = panelText(fact, game.now(), this.endsWhen === 'timer', this.config.minPlayers)
170
+ return panelText(fact, game.now(), this.endsWhen === 'timer', this.config.minPlayers)
171
+ }
172
+
173
+ private paint(): void {
174
+ const text = this.text()
158
175
  if (text === this.painted) return
159
176
  this.painted = text
160
177
  TextShape.createOrReplace(this.entity, {
@@ -26,9 +26,13 @@ owns all three, and health a client can edit is health nobody can trust.
26
26
  - `damage(player, amount)` — WHICH SIDE: the SERVER, inside the `if (isServer())`
27
27
  branch, from a `game.onRequest`, `game.onEnterArea`, `game.every` or
28
28
  `game.onRoundStart` handler. Takes the player to a minimum of 0; at 0 this
29
- prefab respawns them within half a second and refills them. A player who is not
30
- in the scene is ignored.
29
+ prefab refills them and respawns them after respawnDelay, checked every half
30
+ second. A player who is not in the scene is ignored.
31
31
  - `healthOf(player)` — reads hit points on either side, the client included.
32
+ - `heal(player, amount)` and `kill(player)` — SERVER helpers from the same health.ts.
33
+ Healing caps at maxHealth and cannot revive a dead player. kill forces health to zero.
34
+ - `lifeOf(player)` — reads life number, death count, dead status, respawnAt and
35
+ protectedUntil, or null before the player joins. Times use game.now().
32
36
  - `game.state.health` — the whole `{ wallet: points }` map, readable on every
33
37
  client and by late joiners. Read it to draw bars or count who is alive; never
34
38
  write it.
@@ -43,6 +47,25 @@ Params of the prefab's script — set them in the placePrefab request:
43
47
  - `dieBelowHeight`: players below this height die (default 0 = off). This is the
44
48
  death plane for a climbing or platforming scene; set it under the lowest floor
45
49
  a player is allowed to stand on.
50
+ - `respawnDelay`: seconds to remain dead before returning (default 0).
51
+ - `protectionSeconds`: damage/fall protection after returning (default 1).
52
+ - `healthBar`: show hit points on Run Progress's HUD (default on). Set it to
53
+ false when nothing calls `damage()` (falling alone needs no bar) unless the
54
+ creator asks for one. Published as `game.state.healthBar`.
55
+
56
+ Checkpoint destinations and an explicit restart move use the shared
57
+ `src/scripts/runtime/respawn.ts` module; read its header. Health & Respawn must
58
+ be placed to receive the move. Each player's destination overrides respawnAt;
59
+ leaving clears it. The course owns resetting destinations at a new round or
60
+ personal restart; Run Progress does both before requesting the move.
61
+ The owner also publishes game.state.healthLife and game.state.healthMax for HUDs.
62
+
63
+ For a course, read Run Progress and Trigger Area too. Place the start and checkpoint
64
+ markers inside the active parcels, above a collidable landing. Enable
65
+ `ALLOW_TO_MOVE_PLAYER_INSIDE_SCENE` through sceneSettings. Test falling, returning
66
+ to each checkpoint, and Restart run through playtest before finishing the turn.
67
+ If the preview still has the old parcels, retry sceneSettings and wait for success;
68
+ an outside respawn destination is a broken game, not a task to leave to the creator.
46
69
 
47
70
  ## Hazards
48
71
 
@@ -21,8 +21,15 @@
21
21
  "code": "game.onEnterArea('Spikes', (player) => damage(player, 25))",
22
22
  "next": "Press New script below, write that inside the isServer() block it comes with, and import damage from the health.ts beside this item's own script."
23
23
  },
24
- "version": "1.1.0",
24
+ "version": "1.3.1",
25
25
  "changelog": [
26
+ { "version": "1.3.1", "notes": "The health bar is on by default again; turn Health Bar off for a game that never deals damage." },
27
+ { "version": "1.3.0", "notes": "Health bar setting, off by default: a course that never deals damage shows no hit points." },
28
+ { "version": "1.2.1", "notes": "Guide covers in-bounds respawn destinations, permissions and course playtesting." },
29
+ {
30
+ "version": "1.2.0",
31
+ "notes": "Per-player checkpoint destinations, healing, forced death, death counts, optional respawn delay and protection after respawning."
32
+ },
26
33
  {
27
34
  "version": "1.0.0",
28
35
  "notes": "Initial release: hit points the server owns in game.state.health, a respawn point picked in the inspector, a \"die below height\" death plane, and a damage() other server code calls."
@@ -10,10 +10,12 @@ import { Transform, type Entity } from '@dcl/sdk/ecs'
10
10
  import { isServer } from '@dcl/sdk/network'
11
11
  import { movePlayerTo } from '~system/RestrictedActions'
12
12
  import { game, type Player } from '~runtime/game'
13
+ import { clearRespawnPoint, onLocalRespawn, onPlayerRespawn, respawnPlayer } from '~runtime/respawn'
14
+ import type { Vec3 } from '~runtime/pure/worldTransform'
13
15
  import { asHealthMap, clampMax, deadPlayers } from './pure/health'
14
- import { HEALTH_KEY } from './health'
16
+ import { advanceLives, freshLife, readLives, type LifeMap } from './pure/life'
17
+ import { HEALTH_KEY, LIFE_KEY } from './health'
15
18
 
16
- const RESPAWN = 'respawn'
17
19
  const SWEEP_S = 0.5
18
20
  const NO_POINT = "Players won't respawn anywhere until you pick a respawn point."
19
21
 
@@ -32,7 +34,13 @@ export class HealthRespawn {
32
34
  /** Hit points a player starts every life with. */
33
35
  public maxHealth: number = 100,
34
36
  /** Players who fall below this height die. Leave at 0 to switch it off. */
35
- public dieBelowHeight: number = 0
37
+ public dieBelowHeight: number = 0,
38
+ /** Seconds to remain dead before returning to the latest checkpoint. */
39
+ public respawnDelay: number = 0,
40
+ /** Seconds of protection after a respawn. */
41
+ public protectionSeconds: number = 1,
42
+ /** Show hit points on Run Progress's HUD. Turn off when nothing in the game deals damage. */
43
+ public healthBar: boolean = true
36
44
  ) {}
37
45
 
38
46
  start(): void {
@@ -46,35 +54,40 @@ export class HealthRespawn {
46
54
  }
47
55
  if (this.respawnAt === (0 as Entity)) console.log(`[healthRespawn] ${NO_POINT}`)
48
56
  if (isServer()) {
49
- game.onReady(() => game.setState({ [HEALTH_KEY]: {} }))
57
+ onPlayerRespawn((player) => this.restore(player))
58
+ game.onReady(() => game.setState({ [HEALTH_KEY]: {}, [LIFE_KEY]: {}, healthMax: clampMax(this.maxHealth), healthBar: this.healthBar !== false }))
50
59
  game.onPlayerJoin((player) => this.revive(player))
51
60
  game.onPlayerLeave((player) => {
52
61
  const map = { ...asHealthMap(game.state[HEALTH_KEY]) }
62
+ const lives = { ...readLives(game.state[LIFE_KEY]) }
53
63
  delete map[player]
54
- game.setState({ [HEALTH_KEY]: map })
64
+ delete lives[player]
65
+ clearRespawnPoint(player)
66
+ game.setState({ [HEALTH_KEY]: map, [LIFE_KEY]: lives })
55
67
  })
56
68
  game.onRoundStart(() => {
57
69
  const map = asHealthMap(game.state[HEALTH_KEY])
58
70
  const full: Record<string, number> = {}
59
- for (const player of Object.keys(map)) full[player] = clampMax(this.maxHealth)
60
- game.setState({ [HEALTH_KEY]: full })
71
+ const lives: LifeMap = {}
72
+ for (const player of Object.keys(map)) {
73
+ full[player] = clampMax(this.maxHealth)
74
+ lives[player] = freshLife()
75
+ }
76
+ game.setState({ [HEALTH_KEY]: full, [LIFE_KEY]: lives })
61
77
  })
62
78
  game.every(SWEEP_S, () => this.sweep())
63
79
  return
64
80
  }
65
- game.onBroadcast(RESPAWN, () => this.goHome())
81
+ onLocalRespawn((position) => this.goHome(position))
66
82
  }
67
83
 
68
84
  private sweep(): void {
69
85
  const map = asHealthMap(game.state[HEALTH_KEY])
70
- const dead = deadPlayers(map, (player) => game.positionOf(player), this.deathPlane)
71
- if (dead.length === 0) return
72
- const next = { ...map }
73
- for (const player of dead) {
74
- next[player] = clampMax(this.maxHealth)
75
- game.broadcast(RESPAWN, {}, player)
76
- }
77
- game.setState({ [HEALTH_KEY]: next })
86
+ const fallen = new Set(deadPlayers(map, (player) => game.positionOf(player), this.deathPlane))
87
+ const next = advanceLives(map, readLives(game.state[LIFE_KEY]), fallen, game.now(), this.maxHealth, this.respawnDelay, this.protectionSeconds)
88
+ if (!next.changed) return
89
+ game.setState({ [HEALTH_KEY]: next.health, [LIFE_KEY]: next.lives })
90
+ for (const player of next.respawn) respawnPlayer(player)
78
91
  }
79
92
 
80
93
  private get deathPlane(): number {
@@ -83,16 +96,35 @@ export class HealthRespawn {
83
96
 
84
97
  private revive(player: Player): void {
85
98
  game.setState({
86
- [HEALTH_KEY]: { ...asHealthMap(game.state[HEALTH_KEY]), [player]: clampMax(this.maxHealth) }
99
+ [HEALTH_KEY]: { ...asHealthMap(game.state[HEALTH_KEY]), [player]: clampMax(this.maxHealth) },
100
+ [LIFE_KEY]: { ...readLives(game.state[LIFE_KEY]), [player]: freshLife() }
101
+ })
102
+ }
103
+
104
+ private restore(player: Player): void {
105
+ const health = asHealthMap(game.state[HEALTH_KEY])
106
+ if (health[player] === undefined) return
107
+ const lives = readLives(game.state[LIFE_KEY])
108
+ const life = lives[player] ?? freshLife()
109
+ const protection = Number.isFinite(this.protectionSeconds) ? Math.max(0, Math.min(30, this.protectionSeconds)) : 1
110
+ game.setState({
111
+ [HEALTH_KEY]: { ...health, [player]: clampMax(this.maxHealth) },
112
+ [LIFE_KEY]: { ...lives, [player]: {
113
+ ...life, life: life.life + (life.dead ? 1 : 0), dead: false, respawnAt: 0,
114
+ protectedUntil: game.now() + protection * 1000
115
+ } }
87
116
  })
88
117
  }
89
118
 
90
- private goHome(): void {
119
+ private goHome(requested: Vec3 | null): void {
91
120
  const point = this.respawnAt === (0 as Entity) ? null : Transform.getOrNull(this.respawnAt)
92
- if (point === null) {
121
+ const position = requested ?? point?.position
122
+ if (position === undefined) {
93
123
  console.log(`[healthRespawn] ${NO_POINT}`)
94
124
  return
95
125
  }
96
- void movePlayerTo({ newRelativePosition: { ...point.position } })
126
+ void movePlayerTo({ newRelativePosition: { ...position } }).catch((error: unknown) => {
127
+ console.log(`[healthRespawn] could not move the player: ${String(error)}`)
128
+ })
97
129
  }
98
130
  }
@@ -4,8 +4,14 @@
4
4
  // export, so a helper sitting next to the class could be built instead of it.
5
5
  import { game, type Player } from '~runtime/game'
6
6
  import { afterDamage, asHealthMap } from './pure/health'
7
+ import { readLives, type LifeState } from './pure/life'
7
8
 
8
9
  export const HEALTH_KEY = 'health'
10
+ export const LIFE_KEY = 'healthLife'
11
+
12
+ export function lifeOf(player: Player): LifeState | null {
13
+ return readLives(game.state[LIFE_KEY])[player] ?? null
14
+ }
9
15
 
10
16
  /** Hit points a player has right now. 0 means they are on their way back. */
11
17
  export function healthOf(player: Player): number {
@@ -18,5 +24,20 @@ export function healthOf(player: Player): number {
18
24
  * point on their next sweep.
19
25
  */
20
26
  export function damage(player: Player, amount: number): void {
27
+ const life = lifeOf(player)
28
+ if (life?.dead || (life !== null && game.now() < life.protectedUntil)) return
21
29
  game.setState({ [HEALTH_KEY]: afterDamage(asHealthMap(game.state[HEALTH_KEY]), player, amount) })
22
30
  }
31
+
32
+ export function heal(player: Player, amount: number): void {
33
+ const map = asHealthMap(game.state[HEALTH_KEY])
34
+ if (map[player] === undefined || map[player] <= 0 || lifeOf(player)?.dead || !Number.isFinite(amount)) return
35
+ const max = typeof game.state.healthMax === 'number' ? game.state.healthMax : 100
36
+ game.setState({ [HEALTH_KEY]: { ...map, [player]: Math.min(max, map[player] + Math.max(0, Math.round(amount))) } })
37
+ }
38
+
39
+ export function kill(player: Player): void {
40
+ const map = asHealthMap(game.state[HEALTH_KEY])
41
+ if (map[player] === undefined || lifeOf(player)?.dead) return
42
+ game.setState({ [HEALTH_KEY]: { ...map, [player]: 0 } })
43
+ }
@@ -0,0 +1,59 @@
1
+ import { clampMax, type HealthMap } from './health'
2
+
3
+ export interface LifeState {
4
+ life: number
5
+ deaths: number
6
+ dead: boolean
7
+ respawnAt: number
8
+ protectedUntil: number
9
+ }
10
+
11
+ export type LifeMap = Record<string, LifeState>
12
+
13
+ export function freshLife(): LifeState {
14
+ return { life: 1, deaths: 0, dead: false, respawnAt: 0, protectedUntil: 0 }
15
+ }
16
+
17
+ export function readLives(value: unknown): LifeMap {
18
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
19
+ const result: LifeMap = {}
20
+ for (const [player, entry] of Object.entries(value)) {
21
+ if (typeof entry !== 'object' || entry === null) continue
22
+ const row = entry as Record<string, unknown>
23
+ if (typeof row.dead !== 'boolean' || ![row.life, row.deaths, row.respawnAt, row.protectedUntil]
24
+ .every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) continue
25
+ result[player] = {
26
+ life: row.life as number, deaths: row.deaths as number, dead: row.dead,
27
+ respawnAt: row.respawnAt as number, protectedUntil: row.protectedUntil as number
28
+ }
29
+ }
30
+ return result
31
+ }
32
+
33
+ export function advanceLives(
34
+ health: HealthMap, lives: LifeMap, fallen: ReadonlySet<string>, now: number,
35
+ maxHealth: number, delaySeconds: number, protectionSeconds: number
36
+ ): { health: HealthMap; lives: LifeMap; respawn: string[]; changed: boolean } {
37
+ const nextHealth = { ...health }
38
+ const nextLives = { ...lives }
39
+ const respawn: string[] = []
40
+ const delay = Number.isFinite(delaySeconds) ? Math.max(0, Math.min(60, delaySeconds)) * 1000 : 0
41
+ const protection = Number.isFinite(protectionSeconds) ? Math.max(0, Math.min(30, protectionSeconds)) * 1000 : 1000
42
+ let changed = false
43
+ for (const [player, hp] of Object.entries(health)) {
44
+ let life = lives[player] ?? freshLife()
45
+ if (!life.dead && (hp <= 0 || (fallen.has(player) && now >= life.protectedUntil))) {
46
+ life = { ...life, dead: true, deaths: life.deaths + 1, respawnAt: now + delay }
47
+ nextHealth[player] = 0
48
+ changed = true
49
+ }
50
+ if (life.dead && now >= life.respawnAt) {
51
+ life = { ...life, life: life.life + 1, dead: false, respawnAt: 0, protectedUntil: now + protection }
52
+ nextHealth[player] = clampMax(maxHealth)
53
+ respawn.push(player)
54
+ changed = true
55
+ }
56
+ nextLives[player] = life
57
+ }
58
+ return { health: nextHealth, lives: nextLives, respawn, changed }
59
+ }
@@ -4,19 +4,25 @@ prefab: leaderboard
4
4
 
5
5
  # Leaderboard — AI guide
6
6
 
7
- A panel that shows one board: it reads a `game.state` key and paints the places.
8
- It never decides or stores a score.
7
+ A panel or screen overlay that shows game scores or persisted course best times.
8
+ The server validates and stores scores; this prefab displays them.
9
9
 
10
10
  The project copy is normally custom/leaderboard/ — check what is on disk, a
11
11
  second copy is custom/leaderboard_2/.
12
12
 
13
13
  ## When to use
14
14
 
15
- Whenever a game already keeps a ranked list and wants it on a wall. Place one per
16
- board: round scores and all-time bests are two keys, so two panels.
15
+ For a course, place Run Progress and choose Source "Run best times" on this prefab.
16
+ Use the same Course Id (default "course"). Confirmed finishes automatically fill
17
+ the board, fastest first, and survive player departures and server restarts. No
18
+ custom score code is needed. One course has one top-25 board.
17
19
 
18
- Place NOTHING if no server code writes rows — the panel would just show its empty
19
- line. Write the rows first, then place the board on that key.
20
+ The 3D panel is solid and reads from its -z side: at identity rotation players
21
+ stand at -z looking toward +z. Turn the board by rotating its root; never move or
22
+ flip the text child.
23
+
24
+ For other scoring systems choose Source "State key" and provide server-owned rows
25
+ as below. Place one panel per board; use 2D UI when the scores belong on screen.
20
26
 
21
27
  ## API
22
28
 
@@ -37,12 +43,19 @@ There is nothing to import. The contract is the state key:
37
43
 
38
44
  Params of the prefab's script — set them in the placePrefab request:
39
45
 
46
+ - `source`: `State key` (default) or `Run best times`.
47
+ - `courseId`: matches Run Progress when source is `Run best times`.
48
+ - `display`: `3D panel` (default) or `2D UI`. The overlay uses UI Theme.
49
+ - `position`: `left` or `right` for the overlay.
40
50
  - `title`: the heading on the panel (default "Leaderboard").
41
51
  - `boardKey`: the `game.state` key to read (default `'leaderboard'`). Game Flow
42
52
  reads the same key for its winners line — point both at one key.
43
53
  - `sort`: `'desc'` (highest wins — points) or `'asc'` (lowest wins — best times,
44
54
  which also renders the score as m:ss).
45
- - `rows`: how many places the panel lists (default 8, max 25).
55
+ - `rows`: how many places to list (default 8; max 25 in 3D, 10 on screen).
56
+
57
+ For Run best times, Board Key and Sort are hidden: the course supplies the key,
58
+ scores in seconds and ascending order. The shared key is `run:<courseId>:leaderboard`.
46
59
 
47
60
  ## The all-time board idiom
48
61
 
@@ -62,11 +75,16 @@ Then place a second Leaderboard with `boardKey: 'bestTimes'`.
62
75
  number a client reports is a claim.
63
76
  - DON'T write more than the visible places into the key. Fold to the top ten in
64
77
  server code — every extra row rides the wire on every change.
65
- - DO expect wallet addresses. There is no name lookup for a player who is not
66
- connected, so offline players show as a shortened address.
78
+ - Rows show display names: a player in the scene is named live; Run best times
79
+ stores the finisher's name when the server can see it. Give your own rows a
80
+ `name` beside `player` to keep it for offline players; else a short wallet shows.
67
81
 
68
82
  ## Example
69
83
 
84
+ Without code: place Leaderboard, choose Source "Run best times", leave Course Id
85
+ "course" to match Run Progress, and choose Display "2D UI". Finish the course;
86
+ the server-validated time appears. Restarting or leaving does not erase the board.
87
+
70
88
  "Show the fastest climbers": in a handler inside the server branch keep the list,
71
89
  and place a board on that key.
72
90
 
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "513": {
30
30
  "json": {
31
- "position": { "x": 0, "y": 0.45, "z": 0.02 },
31
+ "position": { "x": 0, "y": 0.45, "z": -0.02 },
32
32
  "rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
33
33
  "scale": { "x": 1, "y": 1, "z": 1 },
34
34
  "parent": 512
@@ -42,7 +42,7 @@
42
42
  "512": {
43
43
  "json": {
44
44
  "src": "{assetPath}/board-panel.glb",
45
- "visibleMeshesCollisionMask": 0,
45
+ "visibleMeshesCollisionMask": 2,
46
46
  "invisibleMeshesCollisionMask": 0
47
47
  }
48
48
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "493daede-2afd-4c10-9c0d-6aefb25629a8",
3
3
  "name": "Leaderboard",
4
- "description": "Shows one of your game's boards — point it at the game.state key your game writes the rows to. Works with: Game Flow.",
4
+ "description": "Show game scores or automatic Run Progress best times on a 3D panel or themed screen overlay.",
5
5
  "category": "custom",
6
6
  "group": "Multiplayer Server",
7
7
  "tags": [
@@ -13,13 +13,16 @@
13
13
  "board"
14
14
  ],
15
15
  "requiresSdk": "auth-server",
16
- "drivenBy": {
17
- "rule": "This panel only paints what it reads — the server writes the rows into the synced state key named in Board Key, and every client draws them in order.",
18
- "code": "game.setState({ leaderboard: [{ player, score }] })",
19
- "next": "Press New script below and write that line inside the isServer() block it comes with, using the same key you set in Board Key above."
20
- },
21
- "version": "1.1.0",
16
+ "version": "1.4.0",
22
17
  "changelog": [
18
+ {
19
+ "version": "1.4.0",
20
+ "notes": "The 3D panel's text is readable from its front, and players can no longer walk through the board. A board placed before this keeps its old text position and no collider: delete it and place it again."
21
+ },
22
+ {
23
+ "version": "1.3.0",
24
+ "notes": "Rows show the display name of players in the scene, or a name the row carries; others show a short address."
25
+ },
23
26
  {
24
27
  "version": "1.0.0",
25
28
  "notes": "Initial release: a panel that reads the game.state key you name in boardKey and paints its top rows, so the board a Script keeps is the board every player sees and a late joiner gets it from the snapshot."
@@ -27,6 +30,10 @@
27
30
  {
28
31
  "version": "1.1.0",
29
32
  "notes": "The placed panel's Script card now shows the setState line that writes the rows it paints, under its own settings."
33
+ },
34
+ {
35
+ "version": "1.2.0",
36
+ "notes": "Themed screen overlay and automatic persisted best times from Run Progress."
30
37
  }
31
38
  ],
32
39
  "origin": {
@@ -0,0 +1,28 @@
1
+ import ReactEcs, { ReactEcsRenderer, UiEntity } from '@dcl/sdk/react-ecs'
2
+ import type { Entity } from '@dcl/sdk/ecs'
3
+ import { UI_CANVAS, sceneUiTheme } from '~runtime/uiTheme'
4
+ import { UiPanel, UiText } from '~runtime/uiComponents'
5
+ import { formatScore, rowLabel, type BoardRow, type BoardSort } from './pure/board'
6
+
7
+ export function mountLeaderboardHud(entity: Entity, position: 'left' | 'right', read: () => {
8
+ title: string; rows: BoardRow[]; sort: BoardSort; empty: string
9
+ }): void {
10
+ ReactEcsRenderer.addUiRenderer(entity, () => {
11
+ const theme = sceneUiTheme()
12
+ const board = read()
13
+ const rows = board.rows.slice(0, 10)
14
+ return <UiEntity uiTransform={{ positionType: 'absolute', width: '100%', height: '100%', padding: 28,
15
+ justifyContent: position === 'left' ? 'flex-start' : 'flex-end', alignItems: 'center', pointerFilter: 'none' }}>
16
+ <UiPanel theme={theme} uiTransform={{ width: 420, height: 116 + Math.max(1, rows.length) * 40 }}>
17
+ <UiText value={board.title} color={theme.text} size={26} height={40} />
18
+ <UiText value={board.sort === 'asc' ? 'FASTEST TIMES' : 'HIGH SCORES'} color={theme.muted} size={14} height={24} />
19
+ {rows.length === 0 && <UiText value={board.empty} color={theme.muted} size={16} height={40} />}
20
+ {rows.map((row) => <UiEntity key={row.player} uiTransform={{ width: '100%', height: 40, pointerFilter: 'none' }}>
21
+ <UiText value={String(row.rank).padStart(2, '0')} color={theme.accentText} size={17} mono uiTransform={{ width: 42 }} height={40} />
22
+ <UiText value={rowLabel(row)} color={theme.text} size={18} uiTransform={{ width: 212 }} height={40} />
23
+ <UiText value={formatScore(row.score, board.sort)} color={theme.text} size={18} mono align="middle-right" uiTransform={{ width: 116 }} height={40} />
24
+ </UiEntity>)}
25
+ </UiPanel>
26
+ </UiEntity>
27
+ }, UI_CANVAS)
28
+ }
@@ -7,10 +7,17 @@
7
7
  // The panel is ordinary scene content: swap board-panel.glb for your own model
8
8
  // and keep (or restyle) the text child — the script writes only its `text`, so
9
9
  // font, size and colour stay exactly as authored.
10
- import { TextShape, Transform, engine, type Entity } from '@dcl/sdk/ecs'
10
+ // The text sits on the panel's -z face, where players read it (a TextShape reads
11
+ // from -z); a replacement model must face the same way.
12
+ import { TextShape, Transform, VisibilityComponent, engine, type Entity } from '@dcl/sdk/ecs'
11
13
  import { isServer } from '@dcl/sdk/network'
12
14
  import { game } from '~runtime/game'
13
15
  import { boardRows, clampRows, renderPanel } from './pure/board'
16
+ import { runBoardKey } from '~runtime/pure/runBoard'
17
+ import { playerName } from '~runtime/playerPositions'
18
+ import { mountLeaderboardHud } from './leaderboard-hud'
19
+
20
+ const NAME_REFRESH_S = 1
14
21
 
15
22
  const EMPTY = 'Nothing to show yet — set the board key the server writes to.'
16
23
 
@@ -23,27 +30,56 @@ export class Leaderboard {
23
30
  public entity: Entity,
24
31
  /** The panel's title. */
25
32
  public title: string = 'Leaderboard',
26
- /** The game.state key this board shows. The server writes the rows there. */
33
+ /** For "State key": the game.state key containing the server's scores. */
27
34
  public boardKey: string = 'leaderboard',
28
- /** Which score wins: highest keeps points, lowest keeps best times. */
35
+ /** For "State key": highest keeps points, lowest keeps best times. */
29
36
  public sort: 'desc' | 'asc' = 'desc',
30
37
  /** How many places the panel lists. */
31
- public rows: number = 8
38
+ public rows: number = 8,
39
+ public source: 'State key' | 'Run best times' = 'State key',
40
+ /** For "Run best times": match the Course Id on Run Progress. */
41
+ public courseId: string = 'course',
42
+ public display: '3D panel' | '2D UI' = '3D panel',
43
+ /** For "2D UI": the side of the screen. */
44
+ public position: 'left' | 'right' = 'right'
32
45
  ) {}
33
46
 
34
47
  start(): void {
35
48
  if (isServer()) { return }
49
+ if (this.display === '2D UI') {
50
+ VisibilityComponent.createOrReplace(this.entity, { visible: false, propagateToChildren: true })
51
+ mountLeaderboardHud(this.entity, this.position, () => this.content())
52
+ return
53
+ }
36
54
  this.panel = this.findPanel()
37
55
  game.onStateChange(() => this.repaint())
56
+ // Avatar names arrive after the rows do, and with no state change to repaint on.
57
+ let wait = 0
58
+ engine.addSystem((dt) => {
59
+ wait += dt
60
+ if (wait < NAME_REFRESH_S) return
61
+ wait = 0
62
+ this.repaint()
63
+ })
38
64
  this.repaint()
39
65
  }
40
66
 
41
67
  private repaint(): void {
68
+ const content = this.content()
42
69
  this.paint(
43
- renderPanel(this.title, boardRows(game.state[this.boardKey], this.sort, clampRows(this.rows)), this.sort, EMPTY)
70
+ renderPanel(content.title, content.rows, content.sort, content.empty)
44
71
  )
45
72
  }
46
73
 
74
+ private content() {
75
+ const runs = this.source === 'Run best times'
76
+ const sort: 'asc' | 'desc' = runs ? 'asc' : this.sort
77
+ const rows = boardRows(game.state[runs ? runBoardKey(this.courseId) : this.boardKey], sort, clampRows(this.rows))
78
+ .map((row) => ({ ...row, name: playerName(row.player) ?? row.name }))
79
+ return { title: this.title, sort, rows,
80
+ empty: runs ? 'Complete a run to set the first time.' : EMPTY }
81
+ }
82
+
47
83
  // The text lives on a child so the model can be replaced without touching it.
48
84
  private findPanel(): Entity {
49
85
  for (const [entity, transform] of engine.getEntitiesWith(Transform)) {