hytopia 0.1.60 → 0.1.62
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/bin/scripts.js +6 -4
- package/boilerplate/assets/map.json +2 -1
- package/docs/server.blocktypeoptions.customcollideroptions.md +2 -0
- package/docs/server.blocktypeoptions.id.md +2 -0
- package/docs/server.blocktypeoptions.isliquid.md +2 -0
- package/docs/server.blocktypeoptions.md +8 -2
- package/docs/server.blocktypeoptions.name.md +2 -0
- package/docs/server.blocktypeoptions.textureuri.md +2 -0
- package/docs/server.collisiongroup.md +168 -0
- package/docs/server.entity.md +35 -0
- package/docs/server.entity.opacity.md +13 -0
- package/docs/server.entity.setopacity.md +53 -0
- package/docs/server.entityeventpayload.md +9 -0
- package/docs/server.entityeventpayload.setopacity.entity.md +11 -0
- package/docs/server.entityeventpayload.setopacity.md +70 -0
- package/docs/server.entityeventpayload.setopacity.opacity.md +11 -0
- package/docs/server.entityeventtype.md +14 -0
- package/docs/server.entityoptions.md +19 -0
- package/docs/server.entityoptions.opacity.md +13 -0
- package/docs/server.playerentitycontroller.md +19 -0
- package/docs/server.playerentitycontroller.stickstoplatforms.md +13 -0
- package/docs/server.playerentitycontrolleroptions.md +19 -0
- package/docs/server.playerentitycontrolleroptions.stickstoplatforms.md +13 -0
- package/examples/big-world/package.json +3 -2
- package/examples/block-entity/package.json +3 -2
- package/examples/custom-ui/package.json +3 -2
- package/examples/entity-controller/package.json +3 -2
- package/examples/entity-spawn/package.json +3 -2
- package/examples/payload-game/package.json +3 -2
- package/examples/wall-dodge-game/assets/map.json +3995 -0
- package/examples/wall-dodge-game/assets/textures/water.png +0 -0
- package/examples/wall-dodge-game/assets/ui/index.html +249 -0
- package/examples/wall-dodge-game/index.ts +336 -0
- package/examples/wall-dodge-game/package.json +16 -0
- package/package.json +1 -1
- package/server.api.json +512 -5
- package/server.d.ts +48 -12
- package/server.js +71 -71
Binary file
|
@@ -0,0 +1,249 @@
|
|
1
|
+
<!-- Game Start Animation-->
|
2
|
+
<script>
|
3
|
+
function formatTime(ms) {
|
4
|
+
const totalSeconds = Math.floor(ms / 1000);
|
5
|
+
const minutes = Math.floor(totalSeconds / 60);
|
6
|
+
const seconds = totalSeconds % 60;
|
7
|
+
|
8
|
+
const paddedMinutes = minutes.toString().padStart(2, '0');
|
9
|
+
const paddedSeconds = seconds.toString().padStart(2, '0');
|
10
|
+
|
11
|
+
return `${paddedMinutes}:${paddedSeconds}`;
|
12
|
+
}
|
13
|
+
|
14
|
+
function showGameOver(scoreTime, lastTopScoreTime) {
|
15
|
+
const gameOver = document.getElementById('game-over');
|
16
|
+
const scoreValue = document.getElementById('score-value');
|
17
|
+
const highScoreText = document.getElementById('high-score-text');
|
18
|
+
|
19
|
+
scoreValue.textContent = formatTime(scoreTime);
|
20
|
+
highScoreText.style.display = scoreTime > lastTopScoreTime ? 'block' : 'none';
|
21
|
+
gameOver.style.opacity = 1;
|
22
|
+
|
23
|
+
setTimeout(() => {
|
24
|
+
gameOver.style.opacity = 0;
|
25
|
+
}, 3000);
|
26
|
+
}
|
27
|
+
|
28
|
+
function showGameCountdown() {
|
29
|
+
const el = document.getElementById('countdown');
|
30
|
+
const show = (text, color) => {
|
31
|
+
el.style.opacity = 0;
|
32
|
+
setTimeout(() => {
|
33
|
+
el.textContent = text;
|
34
|
+
el.style.color = color;
|
35
|
+
el.style.opacity = 1;
|
36
|
+
}, 300);
|
37
|
+
};
|
38
|
+
|
39
|
+
[3, 2, 1].forEach((num, i) => {
|
40
|
+
setTimeout(() => show(num, '#ff0000'), i * 1000);
|
41
|
+
});
|
42
|
+
|
43
|
+
setTimeout(() => {
|
44
|
+
show('GO!', '#00ff00');
|
45
|
+
setTimeout(() => el.style.opacity = 0, 1000);
|
46
|
+
}, 3000);
|
47
|
+
}
|
48
|
+
|
49
|
+
function updateLeaderboard(scores) {
|
50
|
+
const entriesDiv = document.getElementById('leaderboard-entries');
|
51
|
+
entriesDiv.innerHTML = '';
|
52
|
+
|
53
|
+
if (!scores.length) {
|
54
|
+
const noScoresRow = document.createElement('div');
|
55
|
+
noScoresRow.className = 'leaderboard-row';
|
56
|
+
noScoresRow.textContent = 'No Top Scores';
|
57
|
+
noScoresRow.style.display = 'flex';
|
58
|
+
noScoresRow.style.justifyContent = 'center';
|
59
|
+
entriesDiv.appendChild(noScoresRow);
|
60
|
+
return;
|
61
|
+
}
|
62
|
+
|
63
|
+
scores.forEach(({name, score}) => {
|
64
|
+
const row = document.createElement('div');
|
65
|
+
row.className = 'leaderboard-row';
|
66
|
+
|
67
|
+
const username = document.createElement('span');
|
68
|
+
username.className = 'username';
|
69
|
+
username.textContent = name;
|
70
|
+
|
71
|
+
const time = document.createElement('span');
|
72
|
+
time.className = 'time';
|
73
|
+
time.textContent = formatTime(score);
|
74
|
+
|
75
|
+
row.appendChild(username);
|
76
|
+
row.appendChild(time);
|
77
|
+
entriesDiv.appendChild(row);
|
78
|
+
});
|
79
|
+
}
|
80
|
+
|
81
|
+
// Server to client UI data handlers
|
82
|
+
hytopia.onData(data => {
|
83
|
+
if (data.type === 'game-end') {
|
84
|
+
showGameOver(data.scoreTime, data.lastTopScoreTime);
|
85
|
+
}
|
86
|
+
|
87
|
+
if (data.type === 'game-start') {
|
88
|
+
showGameCountdown();
|
89
|
+
}
|
90
|
+
|
91
|
+
if (data.type === 'leaderboard') {
|
92
|
+
updateLeaderboard(data.scores);
|
93
|
+
}
|
94
|
+
});
|
95
|
+
|
96
|
+
// Register in-game scene UI templates, so server can
|
97
|
+
// instantiate instance with new SceneUI({ templateId: 'join-npc-message', ...etc });
|
98
|
+
hytopia.registerSceneUITemplate('join-npc-message', () => {
|
99
|
+
const template = document.getElementById('join-npc-message');
|
100
|
+
const clone = template.content.cloneNode(true);
|
101
|
+
return clone;
|
102
|
+
});
|
103
|
+
</script>
|
104
|
+
|
105
|
+
<!-- Game Start Countdown -->
|
106
|
+
<div id="countdown"></div>
|
107
|
+
|
108
|
+
<!-- Game End Animation -->
|
109
|
+
<div id="game-over">
|
110
|
+
<div class="main-text">Game Over!</div>
|
111
|
+
<div class="score-text">Score: <span id="score-value"></span></div>
|
112
|
+
<div id="high-score-text">New personal high score!</div>
|
113
|
+
</div>
|
114
|
+
|
115
|
+
<!-- Leaderboard -->
|
116
|
+
<div class="leaderboard">
|
117
|
+
<h2>Top Survivors</h2>
|
118
|
+
<div id="leaderboard-entries" class="leaderboard-entries">
|
119
|
+
</div>
|
120
|
+
</div>
|
121
|
+
|
122
|
+
<!-- Template for Join NPC Scene UI-->
|
123
|
+
<template id="join-npc-message">
|
124
|
+
<div class="join-npc-message">
|
125
|
+
<h1>Join the game</h1>
|
126
|
+
<p>Jump on my platform to join the game</p>
|
127
|
+
<p style="margin-top: 5px;">(WASD to move, Spacebar to jump)</p>
|
128
|
+
</div>
|
129
|
+
</template>
|
130
|
+
|
131
|
+
<!-- Styles -->
|
132
|
+
<style>
|
133
|
+
* {
|
134
|
+
font-family: Arial, sans-serif;
|
135
|
+
user-select: none;
|
136
|
+
}
|
137
|
+
|
138
|
+
.join-npc-message {
|
139
|
+
background: rgba(0, 0, 0, 0.8);
|
140
|
+
border-radius: 12px;
|
141
|
+
padding: 12px 20px;
|
142
|
+
color: white;
|
143
|
+
text-align: center;
|
144
|
+
position: relative;
|
145
|
+
margin-bottom: 15px;
|
146
|
+
}
|
147
|
+
|
148
|
+
.join-npc-message:after {
|
149
|
+
content: '';
|
150
|
+
position: absolute;
|
151
|
+
bottom: -10px;
|
152
|
+
left: 50%;
|
153
|
+
transform: translateX(-50%);
|
154
|
+
border-left: 10px solid transparent;
|
155
|
+
border-right: 10px solid transparent;
|
156
|
+
border-top: 10px solid rgba(0, 0, 0, 0.8);
|
157
|
+
}
|
158
|
+
|
159
|
+
.join-npc-message h1 {
|
160
|
+
font-size: 18px;
|
161
|
+
margin: 0 0 8px 0;
|
162
|
+
}
|
163
|
+
|
164
|
+
.join-npc-message p {
|
165
|
+
font-size: 14px;
|
166
|
+
margin: 0;
|
167
|
+
}
|
168
|
+
|
169
|
+
#countdown {
|
170
|
+
position: fixed;
|
171
|
+
top: 50%;
|
172
|
+
left: 50%;
|
173
|
+
transform: translate(-50%, -50%);
|
174
|
+
font-size: 120px;
|
175
|
+
font-weight: bold;
|
176
|
+
opacity: 0;
|
177
|
+
transition: opacity 0.3s;
|
178
|
+
text-shadow: 2px 2px 8px rgba(0,0,0,0.8);
|
179
|
+
}
|
180
|
+
|
181
|
+
#game-over {
|
182
|
+
position: fixed;
|
183
|
+
top: 50%;
|
184
|
+
left: 50%;
|
185
|
+
transform: translate(-50%, -50%);
|
186
|
+
text-align: center;
|
187
|
+
opacity: 0;
|
188
|
+
transition: opacity 0.5s;
|
189
|
+
}
|
190
|
+
|
191
|
+
#game-over .main-text {
|
192
|
+
font-size: 120px;
|
193
|
+
font-weight: bold;
|
194
|
+
color: #ff0000;
|
195
|
+
text-shadow: 2px 2px 8px rgba(0,0,0,0.8);
|
196
|
+
}
|
197
|
+
|
198
|
+
#game-over .score-text {
|
199
|
+
font-size: 48px;
|
200
|
+
margin-top: 20px;
|
201
|
+
color: white;
|
202
|
+
text-shadow: 2px 2px 8px rgba(0,0,0,0.8);
|
203
|
+
}
|
204
|
+
|
205
|
+
#game-over #high-score-text {
|
206
|
+
font-size: 36px;
|
207
|
+
margin-top: 15px;
|
208
|
+
color: #ffd700;
|
209
|
+
text-shadow: 2px 2px 8px rgba(0,0,0,0.8);
|
210
|
+
display: none;
|
211
|
+
}
|
212
|
+
|
213
|
+
.leaderboard {
|
214
|
+
position: fixed;
|
215
|
+
top: 20px;
|
216
|
+
right: 20px;
|
217
|
+
background: rgba(0, 0, 0, 0.8);
|
218
|
+
border-radius: 12px;
|
219
|
+
padding: 15px;
|
220
|
+
color: white;
|
221
|
+
min-width: 200px;
|
222
|
+
}
|
223
|
+
|
224
|
+
.leaderboard h2 {
|
225
|
+
font-size: 18px;
|
226
|
+
margin: 0 0 12px 0;
|
227
|
+
text-align: center;
|
228
|
+
}
|
229
|
+
|
230
|
+
.leaderboard-entries {
|
231
|
+
display: flex;
|
232
|
+
flex-direction: column;
|
233
|
+
gap: 8px;
|
234
|
+
}
|
235
|
+
|
236
|
+
.leaderboard-row {
|
237
|
+
display: flex;
|
238
|
+
justify-content: space-between;
|
239
|
+
font-size: 14px;
|
240
|
+
}
|
241
|
+
|
242
|
+
.username {
|
243
|
+
color: #fff;
|
244
|
+
}
|
245
|
+
|
246
|
+
.time {
|
247
|
+
color: #ffd700;
|
248
|
+
}
|
249
|
+
</style>
|
@@ -0,0 +1,336 @@
|
|
1
|
+
import {
|
2
|
+
CollisionGroup,
|
3
|
+
ColliderShape,
|
4
|
+
BlockType,
|
5
|
+
Entity,
|
6
|
+
GameServer,
|
7
|
+
SceneUI,
|
8
|
+
startServer,
|
9
|
+
Player,
|
10
|
+
PlayerEntity,
|
11
|
+
PlayerEntityController,
|
12
|
+
RigidBodyType,
|
13
|
+
SimpleEntityController,
|
14
|
+
World,
|
15
|
+
} from 'hytopia';
|
16
|
+
|
17
|
+
import worldMap from './assets/map.json';
|
18
|
+
|
19
|
+
const GAME_BLOCK_SIZE_RANGE = {
|
20
|
+
x: [ 0.5, 4 ],
|
21
|
+
y: [ 0.5, 4 ],
|
22
|
+
z: [ 0.5, 1.5 ],
|
23
|
+
};
|
24
|
+
|
25
|
+
const GAME_BLOCK_SPAWN_RANGE = {
|
26
|
+
x: [ -7, 7 ],
|
27
|
+
y: [ 1, 4 ],
|
28
|
+
z: [ -25, -25 ],
|
29
|
+
};
|
30
|
+
|
31
|
+
const GAME_BLOCK_RANDOM_TEXTURES = [
|
32
|
+
'textures/grass',
|
33
|
+
'textures/bricks.png',
|
34
|
+
'textures/glass.png',
|
35
|
+
'textures/gravel.png',
|
36
|
+
'textures/sand.png',
|
37
|
+
'textures/void_sand.png',
|
38
|
+
];
|
39
|
+
|
40
|
+
const GAME_BLOCK_DESPAWN_Z = 12; // Blocks will despawn when block z position is > than this.
|
41
|
+
|
42
|
+
const GAME_BLOCK_COLLISION_GROUP = CollisionGroup.GROUP_1;
|
43
|
+
|
44
|
+
const PLAYER_GAME_START_TIME = new Map<Player, number>(); // Player -> start time of current game
|
45
|
+
const PLAYER_TOP_SCORES = new Map<Player, number>(); // Player -> highest ever score
|
46
|
+
let GAME_TOP_SCORES: { name: string, score: number }[] = []; // array user [name, score]
|
47
|
+
|
48
|
+
startServer(world => {
|
49
|
+
// Uncomment this to visualize physics vertices, will cause noticable lag.
|
50
|
+
// world.simulation.enableDebugRendering(true);
|
51
|
+
|
52
|
+
world.loadMap(worldMap);
|
53
|
+
world.onPlayerJoin = player => onPlayerJoin(world, player);
|
54
|
+
world.onPlayerLeave = player => onPlayerLeave(world, player);
|
55
|
+
|
56
|
+
setupJoinNPC(world);
|
57
|
+
startBlockSpawner(world);
|
58
|
+
});
|
59
|
+
|
60
|
+
/**
|
61
|
+
* Creates and sets up the NPC the player can interact
|
62
|
+
* with to join the game.
|
63
|
+
*/
|
64
|
+
function setupJoinNPC(world: World) {
|
65
|
+
let focusedPlayer: PlayerEntity | null = null;
|
66
|
+
|
67
|
+
// Create our NPC
|
68
|
+
const joinNPC = new Entity({
|
69
|
+
controller: new SimpleEntityController(),
|
70
|
+
name: 'Join NPC',
|
71
|
+
modelUri: 'models/mindflayer.gltf',
|
72
|
+
modelLoopedAnimations: [ 'idle' ],
|
73
|
+
modelScale: 0.5,
|
74
|
+
rigidBodyOptions: {
|
75
|
+
type: RigidBodyType.FIXED, // It won't ever move, so we can use a fixed body
|
76
|
+
rotation: { x: 0, y: 1, z: 0, w: 0 }, // Rotate the NPC to face the player
|
77
|
+
colliders: [
|
78
|
+
{ // Hitbox/body collider
|
79
|
+
shape: ColliderShape.CYLINDER,
|
80
|
+
radius: 0.3,
|
81
|
+
halfHeight: 1.2,
|
82
|
+
tag: 'body',
|
83
|
+
},
|
84
|
+
{ // Create a sensor that teleports the player into the game
|
85
|
+
shape: ColliderShape.BLOCK,
|
86
|
+
halfExtents: { x: 1.5, y: 1, z: 1.5 }, // size it slightly smaller than the platform the join NPC is standing on
|
87
|
+
isSensor: true,
|
88
|
+
tag: 'teleport-sensor',
|
89
|
+
onCollision: (other: BlockType | Entity, started: boolean) => {
|
90
|
+
if (started && other instanceof PlayerEntity) {
|
91
|
+
startGame(other); // When a player entity enters this sensor, start the game for them
|
92
|
+
}
|
93
|
+
},
|
94
|
+
},
|
95
|
+
{ // Create a sensor to detect players for a fun rotation effect
|
96
|
+
shape: ColliderShape.CYLINDER,
|
97
|
+
radius: 5,
|
98
|
+
halfHeight: 2,
|
99
|
+
isSensor: true, // This makes the collider not collide with other entities/objets
|
100
|
+
tag: 'rotate-sensor',
|
101
|
+
onCollision: (other: BlockType | Entity, started: boolean) => {
|
102
|
+
if (started && other instanceof PlayerEntity) {
|
103
|
+
focusedPlayer = other;
|
104
|
+
}
|
105
|
+
},
|
106
|
+
},
|
107
|
+
],
|
108
|
+
},
|
109
|
+
});
|
110
|
+
|
111
|
+
// Rotate to face the last focused player position every 250ms
|
112
|
+
setInterval(() => {
|
113
|
+
if (focusedPlayer?.isSpawned) {
|
114
|
+
(joinNPC.controller! as SimpleEntityController).face(focusedPlayer.position, 2);
|
115
|
+
}
|
116
|
+
}, 250);
|
117
|
+
|
118
|
+
// Create the Scene UI over the NPC
|
119
|
+
const npcMessageUI = new SceneUI({
|
120
|
+
templateId: 'join-npc-message',
|
121
|
+
attachedToEntity: joinNPC,
|
122
|
+
offset: { x: 0, y: 1.75, z: 0 },
|
123
|
+
});
|
124
|
+
|
125
|
+
npcMessageUI.load(world);
|
126
|
+
|
127
|
+
joinNPC.spawn(world, { x: 1, y: 3.1, z: 15 });
|
128
|
+
}
|
129
|
+
|
130
|
+
/**
|
131
|
+
* Start spawning blocks in the game
|
132
|
+
*/
|
133
|
+
function startBlockSpawner(world: World) {
|
134
|
+
const spawnBlock = () => {
|
135
|
+
// Calculate random number of blocks to spawn (1-8)
|
136
|
+
const numBlocks = Math.floor(Math.random() * 8) + 1;
|
137
|
+
|
138
|
+
for (let i = 0; i < numBlocks; i++) {
|
139
|
+
// Calculate random half extents within allowed ranges
|
140
|
+
const x = Math.max(0.5, Math.random() * GAME_BLOCK_SIZE_RANGE.x[1]);
|
141
|
+
const y = Math.max(0.5, Math.random() * GAME_BLOCK_SIZE_RANGE.y[1]);
|
142
|
+
const z = Math.max(0.5, Math.random() * GAME_BLOCK_SIZE_RANGE.z[1]);
|
143
|
+
|
144
|
+
const halfExtents = {
|
145
|
+
x: y > 0.5 ? 0.5 : x,
|
146
|
+
y: x > 0.5 ? 0.5 : y,
|
147
|
+
z,
|
148
|
+
};
|
149
|
+
|
150
|
+
// Calculate spawn point ranges accounting for block size
|
151
|
+
const spawnPoint = {
|
152
|
+
x: Math.random() * (GAME_BLOCK_SPAWN_RANGE.x[1] - GAME_BLOCK_SPAWN_RANGE.x[0] - halfExtents.x * 2) + GAME_BLOCK_SPAWN_RANGE.x[0] + halfExtents.x,
|
153
|
+
y: Math.random() * (GAME_BLOCK_SPAWN_RANGE.y[1] - GAME_BLOCK_SPAWN_RANGE.y[0] - halfExtents.y * 2) + GAME_BLOCK_SPAWN_RANGE.y[0] + halfExtents.y,
|
154
|
+
z: Math.random() * (GAME_BLOCK_SPAWN_RANGE.z[1] - GAME_BLOCK_SPAWN_RANGE.z[0] - halfExtents.z * 2) + GAME_BLOCK_SPAWN_RANGE.z[0] + halfExtents.z,
|
155
|
+
};
|
156
|
+
|
157
|
+
// Calculate random velocity between 5-10
|
158
|
+
const linearVelocity = 5 + Math.random() * 5;
|
159
|
+
|
160
|
+
// Apply random angular velocity 10% of the time
|
161
|
+
const angularVelocity = Math.random() < 0.1 ? {
|
162
|
+
x: (Math.random() - 0.5) * 5, // -5 to 5
|
163
|
+
y: (Math.random() - 0.5) * 5,
|
164
|
+
z: (Math.random() - 0.5) * 5,
|
165
|
+
} : {
|
166
|
+
x: 0,
|
167
|
+
y: 0,
|
168
|
+
z: 0,
|
169
|
+
};
|
170
|
+
|
171
|
+
const blockEntity = new Entity({
|
172
|
+
blockTextureUri: GAME_BLOCK_RANDOM_TEXTURES[Math.floor(Math.random() * GAME_BLOCK_RANDOM_TEXTURES.length)],
|
173
|
+
blockHalfExtents: halfExtents,
|
174
|
+
rigidBodyOptions: {
|
175
|
+
type: RigidBodyType.KINEMATIC_VELOCITY,
|
176
|
+
linearVelocity: { x: 0, y: 0, z: linearVelocity },
|
177
|
+
angularVelocity,
|
178
|
+
},
|
179
|
+
});
|
180
|
+
|
181
|
+
blockEntity.onTick = () => {
|
182
|
+
if (blockEntity.isSpawned && blockEntity.position.z > GAME_BLOCK_DESPAWN_Z) {
|
183
|
+
// TODO: drop it out of the world, despawn to fix platform collision bug
|
184
|
+
blockEntity.despawn();
|
185
|
+
}
|
186
|
+
};
|
187
|
+
|
188
|
+
blockEntity.spawn(world, spawnPoint);
|
189
|
+
|
190
|
+
// Set groups after spawn so they apply to internally generated colliders too.
|
191
|
+
blockEntity.setCollisionGroupsForSolidColliders({
|
192
|
+
belongsTo: [ GAME_BLOCK_COLLISION_GROUP ],
|
193
|
+
collidesWith: [ CollisionGroup.PLAYER ],
|
194
|
+
});
|
195
|
+
}
|
196
|
+
|
197
|
+
setTimeout(spawnBlock, 250 + Math.random() * 750);
|
198
|
+
};
|
199
|
+
|
200
|
+
spawnBlock();
|
201
|
+
}
|
202
|
+
|
203
|
+
function startGame(playerEntity: PlayerEntity) {
|
204
|
+
playerEntity.setPosition({ x: 1, y: 4, z: 1 });
|
205
|
+
playerEntity.setOpacity(0.3);
|
206
|
+
playerEntity.player.ui.sendData({ type: 'game-start' });
|
207
|
+
enablePlayerEntityGameCollisions(playerEntity, false);
|
208
|
+
|
209
|
+
PLAYER_GAME_START_TIME.set(playerEntity.player, Date.now());
|
210
|
+
|
211
|
+
setTimeout(() => { // Game starts!
|
212
|
+
if (!playerEntity.isSpawned) return;
|
213
|
+
|
214
|
+
playerEntity.setOpacity(1);
|
215
|
+
enablePlayerEntityGameCollisions(playerEntity, true);
|
216
|
+
}, 3500);
|
217
|
+
}
|
218
|
+
|
219
|
+
function endGame(playerEntity: PlayerEntity) {
|
220
|
+
const startTime = PLAYER_GAME_START_TIME.get(playerEntity.player) ?? Date.now();
|
221
|
+
const scoreTime = Date.now() - startTime;
|
222
|
+
const lastTopScoreTime = PLAYER_TOP_SCORES.get(playerEntity.player) ?? 0;
|
223
|
+
|
224
|
+
if (scoreTime > lastTopScoreTime) {
|
225
|
+
PLAYER_TOP_SCORES.set(playerEntity.player, scoreTime);
|
226
|
+
}
|
227
|
+
|
228
|
+
playerEntity.player.ui.sendData({
|
229
|
+
type: 'game-end',
|
230
|
+
scoreTime,
|
231
|
+
lastTopScoreTime,
|
232
|
+
});
|
233
|
+
|
234
|
+
// Reset player to lobby area
|
235
|
+
playerEntity.setLinearVelocity({ x: 0, y: 0, z: 0 });
|
236
|
+
playerEntity.setPosition(getRandomSpawnCoordinate());
|
237
|
+
|
238
|
+
if (playerEntity.world) {
|
239
|
+
updateTopScores();
|
240
|
+
}
|
241
|
+
}
|
242
|
+
|
243
|
+
/**
|
244
|
+
* Handle players joining the game.
|
245
|
+
* We create an initial player entity they control
|
246
|
+
* and set up their entity's collision groups to not collider
|
247
|
+
* with other players.
|
248
|
+
*/
|
249
|
+
function onPlayerJoin(world: World, player: Player) {
|
250
|
+
// Load the game UI for the player
|
251
|
+
player.ui.load('ui/index.html');
|
252
|
+
sendPlayerLeaderboardData(player);
|
253
|
+
|
254
|
+
// Create the player entity
|
255
|
+
const playerEntity = new PlayerEntity({
|
256
|
+
player,
|
257
|
+
name: 'Player',
|
258
|
+
modelUri: 'models/player.gltf',
|
259
|
+
modelLoopedAnimations: [ 'idle' ],
|
260
|
+
modelScale: 0.5,
|
261
|
+
});
|
262
|
+
|
263
|
+
(playerEntity.controller as PlayerEntityController).sticksToPlatforms = false;
|
264
|
+
|
265
|
+
playerEntity.onTick = () => {
|
266
|
+
if (playerEntity.position.y < -3) {
|
267
|
+
// Assume the player has fallen off the map in the game
|
268
|
+
endGame(playerEntity);
|
269
|
+
}
|
270
|
+
};
|
271
|
+
|
272
|
+
// Spawn with a random X coordinate to spread players out a bit.
|
273
|
+
playerEntity.spawn(world, getRandomSpawnCoordinate());
|
274
|
+
}
|
275
|
+
|
276
|
+
/**
|
277
|
+
* Despawn the player's entity and perform any other
|
278
|
+
* cleanup when they leave the game.
|
279
|
+
*/
|
280
|
+
function onPlayerLeave(world: World, player: Player) {
|
281
|
+
world.entityManager.getAllPlayerEntities(player).forEach(entity => {
|
282
|
+
endGame(entity); // explicitly end their game if they leave
|
283
|
+
entity.despawn(); // despawn their entity
|
284
|
+
});
|
285
|
+
}
|
286
|
+
|
287
|
+
/**
|
288
|
+
* Set collision groups for in the game.
|
289
|
+
* Enabled determines if we hit moving blocks, we can set this to false
|
290
|
+
* to give players a bit of time to setup before the game starts.
|
291
|
+
* We also con't collide with other players.
|
292
|
+
* Collision groups work on if both contacted colliders belong to a group the other collides with.
|
293
|
+
*/
|
294
|
+
function enablePlayerEntityGameCollisions(playerEntity: PlayerEntity, enabled: boolean) {
|
295
|
+
playerEntity.colliders.forEach(collider => {
|
296
|
+
collider.setCollisionGroups({
|
297
|
+
belongsTo: [ CollisionGroup.ENTITY, CollisionGroup.PLAYER ],
|
298
|
+
collidesWith: enabled ?
|
299
|
+
[ CollisionGroup.BLOCK, CollisionGroup.ENTITY_SENSOR, GAME_BLOCK_COLLISION_GROUP ] :
|
300
|
+
[ CollisionGroup.BLOCK, CollisionGroup.ENTITY_SENSOR ],
|
301
|
+
});
|
302
|
+
});
|
303
|
+
}
|
304
|
+
|
305
|
+
function getRandomSpawnCoordinate() {
|
306
|
+
const randomX = Math.floor(Math.random() * 15) - 6;
|
307
|
+
|
308
|
+
return { x: randomX, y: 10, z: 22 };
|
309
|
+
}
|
310
|
+
|
311
|
+
function updateTopScores() {
|
312
|
+
const topScores = Array.from(PLAYER_TOP_SCORES.entries())
|
313
|
+
.sort((a, b) => a[1] - b[1])
|
314
|
+
.map(([ player, score ]) => ({ player, score }));
|
315
|
+
|
316
|
+
// Get the top 10 highest scores
|
317
|
+
const updatedTopScores = topScores.slice(0, 10).map(({ player, score }) => ({ name: player.username, score }));
|
318
|
+
|
319
|
+
// Convert both arrays to strings for comparison
|
320
|
+
const currentScoresStr = JSON.stringify(GAME_TOP_SCORES);
|
321
|
+
const updatedScoresStr = JSON.stringify(updatedTopScores);
|
322
|
+
|
323
|
+
// Only update if scores have changed
|
324
|
+
if (currentScoresStr !== updatedScoresStr) {
|
325
|
+
GAME_TOP_SCORES = updatedTopScores;
|
326
|
+
}
|
327
|
+
|
328
|
+
GameServer.instance.playerManager.getConnectedPlayers().forEach(sendPlayerLeaderboardData);
|
329
|
+
}
|
330
|
+
|
331
|
+
function sendPlayerLeaderboardData(player: Player) {
|
332
|
+
player.ui.sendData({
|
333
|
+
type: 'leaderboard',
|
334
|
+
scores: GAME_TOP_SCORES,
|
335
|
+
});
|
336
|
+
}
|
@@ -0,0 +1,16 @@
|
|
1
|
+
{
|
2
|
+
"name": "payload-game",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "",
|
5
|
+
"main": "index.js",
|
6
|
+
"scripts": {
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
8
|
+
},
|
9
|
+
"keywords": [],
|
10
|
+
"author": "",
|
11
|
+
"license": "ISC",
|
12
|
+
"dependencies": {
|
13
|
+
"hytopia": "latest",
|
14
|
+
"@hytopia.com/assets": "latest"
|
15
|
+
}
|
16
|
+
}
|