|
| 1 | +import { |
| 2 | + Entity, |
| 3 | + InputAction, |
| 4 | + Material, |
| 5 | + MeshCollider, |
| 6 | + MeshRenderer, |
| 7 | + TextAlignMode, |
| 8 | + TextShape, |
| 9 | + Transform, |
| 10 | + engine, |
| 11 | + pointerEventsSystem |
| 12 | +} from '@dcl/sdk/ecs' |
| 13 | +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' |
| 14 | +import { isStateSyncronized } from '@dcl/sdk/network' |
| 15 | +import { ORB_POSITION } from '../shared/config' |
| 16 | +import { room } from '../shared/messages' |
| 17 | +import { isServerAlive, pollHeartbeat, setMyScore, showToast } from './state' |
| 18 | + |
| 19 | +// A claim queued locally, waiting for the room to be synced before it is sent. |
| 20 | +let pendingClaim = false |
| 21 | + |
| 22 | +export function setupClient(): void { |
| 23 | + buildScene() |
| 24 | + |
| 25 | + // Server → me: my new authoritative total. |
| 26 | + room.onMessage('scoreUpdate', (data) => setMyScore(data.total)) |
| 27 | + |
| 28 | + // Server → me: claim was rejected (too far, etc.). |
| 29 | + room.onMessage('claimRejected', (data) => showToast(data.reason)) |
| 30 | + |
| 31 | + engine.addSystem(claimRetrySystem) |
| 32 | +} |
| 33 | + |
| 34 | +// Build the local, non-synced visuals. These are client-only decorations — the |
| 35 | +// only synced state in this scene is owned and created by the server. |
| 36 | +function buildScene(): void { |
| 37 | + // Ground platform covering both parcels (32 × 16 m). |
| 38 | + const ground = engine.addEntity() |
| 39 | + Transform.create(ground, { |
| 40 | + position: Vector3.create(16, 0, 8), |
| 41 | + scale: Vector3.create(32, 0.1, 16) |
| 42 | + }) |
| 43 | + MeshRenderer.setBox(ground) |
| 44 | + MeshCollider.setBox(ground) |
| 45 | + Material.setPbrMaterial(ground, { albedoColor: Color4.fromHexString('#1b2a4aff') }) |
| 46 | + |
| 47 | + // Pedestal under the orb. |
| 48 | + const pedestal = engine.addEntity() |
| 49 | + Transform.create(pedestal, { |
| 50 | + position: Vector3.create(ORB_POSITION.x, 0.5, ORB_POSITION.z), |
| 51 | + scale: Vector3.create(1.2, 1, 1.2) |
| 52 | + }) |
| 53 | + MeshRenderer.setCylinder(pedestal) |
| 54 | + MeshCollider.setCylinder(pedestal) |
| 55 | + Material.setPbrMaterial(pedestal, { albedoColor: Color4.fromHexString('#3a4a6bff') }) |
| 56 | + |
| 57 | + // The clickable score orb (glowing emissive sphere). |
| 58 | + const orb = engine.addEntity() |
| 59 | + Transform.create(orb, { position: ORB_POSITION, scale: Vector3.create(0.9, 0.9, 0.9) }) |
| 60 | + MeshRenderer.setSphere(orb) |
| 61 | + MeshCollider.setSphere(orb) // required for the pointer raycast to hit the orb |
| 62 | + Material.setPbrMaterial(orb, { |
| 63 | + albedoColor: Color4.fromHexString('#ffd34eff'), |
| 64 | + emissiveColor: Color4.fromHexString('#ffb000ff'), |
| 65 | + emissiveIntensity: 2 |
| 66 | + }) |
| 67 | + pointerEventsSystem.onPointerDown( |
| 68 | + { entity: orb, opts: { button: InputAction.IA_POINTER, hoverText: 'Score a point!' } }, |
| 69 | + tryClaim |
| 70 | + ) |
| 71 | + |
| 72 | + // Floating title sign. |
| 73 | + const sign = engine.addEntity() |
| 74 | + Transform.create(sign, { |
| 75 | + position: Vector3.create(ORB_POSITION.x, 3.4, ORB_POSITION.z), |
| 76 | + rotation: Quaternion.fromEulerDegrees(0, 180, 0) |
| 77 | + }) |
| 78 | + TextShape.create(sign, { |
| 79 | + text: 'AUTHORITATIVE SERVER\nLEADERBOARD\n\nClick the orb to score', |
| 80 | + fontSize: 3, |
| 81 | + textColor: Color4.White(), |
| 82 | + textAlign: TextAlignMode.TAM_MIDDLE_CENTER |
| 83 | + }) |
| 84 | +} |
| 85 | + |
| 86 | +// The orb was clicked. We never send a score — only the intent to claim a point. |
| 87 | +function tryClaim(): void { |
| 88 | + // Server-not-alive is a long (cold-start) failure that may never resolve — tell |
| 89 | + // the player explicitly instead of silently buffering a click that goes nowhere. |
| 90 | + if (!isServerAlive()) { |
| 91 | + showToast('Server is waking up — try again in a moment…') |
| 92 | + return |
| 93 | + } |
| 94 | + // Room-not-synced is a brief (~1 s) load-time blip — buffer and auto-fire. |
| 95 | + pendingClaim = true |
| 96 | +} |
| 97 | + |
| 98 | +function claimRetrySystem(): void { |
| 99 | + // Keep the client's view of server liveness fresh. |
| 100 | + pollHeartbeat() |
| 101 | + |
| 102 | + if (!pendingClaim) return |
| 103 | + if (!isStateSyncronized()) return |
| 104 | + |
| 105 | + room.send('claimPoint', { nonce: 0 }) |
| 106 | + pendingClaim = false |
| 107 | +} |
0 commit comments