@dcl-regenesislabs/web-editor 0.1.1-37517974772.commit-b9847a1 → 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.
- package/editor.js +1231 -464
- package/package.json +2 -2
- package/prefabs/announcer/ai.md +9 -2
- package/prefabs/announcer/data.json +6 -7
- package/prefabs/announcer/scripts/announcer.tsx +16 -37
- package/prefabs/catalog.json +1 -1
- package/prefabs/game-flow/ai.md +6 -0
- package/prefabs/game-flow/data.json +7 -3
- package/prefabs/game-flow/scripts/game-flow.ts +22 -5
- package/prefabs/health-respawn/ai.md +25 -2
- package/prefabs/health-respawn/data.json +8 -1
- package/prefabs/health-respawn/scripts/health-respawn.ts +52 -20
- package/prefabs/health-respawn/scripts/health.ts +21 -0
- package/prefabs/health-respawn/scripts/pure/life.ts +59 -0
- package/prefabs/leaderboard/ai.md +27 -9
- package/prefabs/leaderboard/composite.json +2 -2
- package/prefabs/leaderboard/data.json +14 -7
- package/prefabs/leaderboard/scripts/leaderboard-hud.tsx +28 -0
- package/prefabs/leaderboard/scripts/leaderboard.ts +41 -5
- package/prefabs/leaderboard/scripts/pure/board.ts +10 -3
- package/prefabs/run-progress/ai.md +101 -0
- package/prefabs/run-progress/composite.json +9 -0
- package/prefabs/run-progress/data.json +43 -0
- package/prefabs/run-progress/scripts/moving-platform.ts +28 -0
- package/prefabs/run-progress/scripts/run-progress.tsx +170 -0
- package/prefabs/run-progress/thumbnail.png +0 -0
- package/prefabs/server-clock/ai.md +10 -7
- package/prefabs/server-clock/data.json +5 -1
- package/prefabs/server-clock/scripts/server-clock.ts +7 -6
- package/prefabs/trigger-zone/ai.md +13 -7
- package/prefabs/trigger-zone/data.json +2 -1
- package/prefabs/ui-theme/ai.md +30 -0
- package/prefabs/ui-theme/composite.json +31 -0
- package/prefabs/ui-theme/data.json +24 -0
- package/prefabs/ui-theme/scripts/ui-theme.ts +16 -0
- package/prefabs/ui-theme/thumbnail.png +0 -0
- package/scene/about +1 -1
- package/scene/{bafkreidzfny4im7pqpzuedreg2q5e6pd3vokz7h2py5b6zjxn3er6h4f5i → bafkreifyskofqvjvp22m3eaxz2mpwibp7j4wtimdj5bxtqsd7onkbnjlsa} +1 -1
- package/scene/bafybeiblcndgruoz3ewqrheei4moyeoehankul4yd43co33djksumfd3se +90 -0
- package/three/worker.js +3832 -0
- package/web-build/index.js +6 -3
- package/web-build/sdk-snapshot.json +1 -1
- package/web-build/worker.js +29 -26
- package/dcl-regenesislabs-web-editor-0.1.1-37517974772.commit-b9847a1.tgz +0 -0
- package/prefabs/server-clock/scripts/clock-hud.tsx +0 -55
- 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
|
|
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.
|
|
17
|
+
"version": "1.1.0",
|
|
18
18
|
"changelog": [
|
|
19
19
|
{
|
|
20
20
|
"version": "1.0.0",
|
|
21
|
-
"notes": "Initial release: lobby
|
|
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
|
|
168
|
+
private text(): string {
|
|
156
169
|
const fact = asFlowFact(game.state[FLOW_KEY])
|
|
157
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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: { ...
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
-
|
|
66
|
-
|
|
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":
|
|
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": "
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
/**
|
|
33
|
+
/** For "State key": the game.state key containing the server's scores. */
|
|
27
34
|
public boardKey: string = 'leaderboard',
|
|
28
|
-
/**
|
|
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(
|
|
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)) {
|