terminal-mmo 0.1.0
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/dist/cli.js +2274 -0
- package/package.json +39 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2274 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// ../shared/src/constants.ts
|
|
5
|
+
var WORLD = { w: 240, h: 40 };
|
|
6
|
+
var GROUND_TOP = WORLD.h - 3;
|
|
7
|
+
var BOX = { w: 5, h: 5 };
|
|
8
|
+
var PHYS = {
|
|
9
|
+
speed: 22,
|
|
10
|
+
jump: 34,
|
|
11
|
+
grav: 90,
|
|
12
|
+
maxDt: 0.05
|
|
13
|
+
};
|
|
14
|
+
var COMBAT = {
|
|
15
|
+
meleeReach: 6,
|
|
16
|
+
meleeDamage: 8,
|
|
17
|
+
attackCooldown: 0.35,
|
|
18
|
+
iframes: 0.6
|
|
19
|
+
};
|
|
20
|
+
var MONSTER = {
|
|
21
|
+
chaserHp: 24,
|
|
22
|
+
chaserSpeed: 12,
|
|
23
|
+
chaserAggro: 22,
|
|
24
|
+
chaserDeadzone: 2,
|
|
25
|
+
contactDamage: 6
|
|
26
|
+
};
|
|
27
|
+
var SHOOTER = {
|
|
28
|
+
hp: 16,
|
|
29
|
+
speed: 9,
|
|
30
|
+
aggro: 46,
|
|
31
|
+
keepDist: 20,
|
|
32
|
+
fireCooldown: 1.4,
|
|
33
|
+
projSpeed: 36,
|
|
34
|
+
projLife: 2.4,
|
|
35
|
+
projDamage: 7
|
|
36
|
+
};
|
|
37
|
+
var PROJECTILE = { w: 1, h: 1 };
|
|
38
|
+
var PROGRESSION = { levelCap: 30 };
|
|
39
|
+
var SPAWN = { x: 10, y: GROUND_TOP - BOX.h };
|
|
40
|
+
var TOWN = { w: 80 };
|
|
41
|
+
var TOWN_SPAWN = { x: 12, y: GROUND_TOP - BOX.h };
|
|
42
|
+
var RESPAWN = { delaySec: 5 };
|
|
43
|
+
var XP_PER_KILL = 12;
|
|
44
|
+
var CHAT_MAX_LEN = 120;
|
|
45
|
+
|
|
46
|
+
// ../shared/src/combat.ts
|
|
47
|
+
function entityBox(e) {
|
|
48
|
+
return { x: e.x, y: e.y, w: BOX.w, h: BOX.h };
|
|
49
|
+
}
|
|
50
|
+
function meleeHitbox(p) {
|
|
51
|
+
const w = COMBAT.meleeReach;
|
|
52
|
+
return {
|
|
53
|
+
x: p.facing === 1 ? p.x + BOX.w : p.x - w,
|
|
54
|
+
y: p.y,
|
|
55
|
+
w,
|
|
56
|
+
h: BOX.h
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function aabbOverlap(a, b) {
|
|
60
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
61
|
+
}
|
|
62
|
+
// ../shared/src/rng.ts
|
|
63
|
+
function rngNext(state) {
|
|
64
|
+
const s = state + 1831565813 | 0;
|
|
65
|
+
let t = s;
|
|
66
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
67
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
68
|
+
const value = ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
69
|
+
return { value, state: s };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ../shared/src/loot.ts
|
|
73
|
+
var BASES = [
|
|
74
|
+
{ name: "Rusty Sword", slot: "weapon" },
|
|
75
|
+
{ name: "Iron Sword", slot: "weapon" },
|
|
76
|
+
{ name: "Leather Vest", slot: "armor" },
|
|
77
|
+
{ name: "Chain Mail", slot: "armor" },
|
|
78
|
+
{ name: "Copper Ring", slot: "accessory" },
|
|
79
|
+
{ name: "Jade Amulet", slot: "accessory" }
|
|
80
|
+
];
|
|
81
|
+
var RARITIES = [
|
|
82
|
+
{ name: "common", weight: 60, affixes: 1 },
|
|
83
|
+
{ name: "uncommon", weight: 25, affixes: 2 },
|
|
84
|
+
{ name: "rare", weight: 10, affixes: 3 },
|
|
85
|
+
{ name: "epic", weight: 4, affixes: 4 },
|
|
86
|
+
{ name: "legendary", weight: 1, affixes: 5 }
|
|
87
|
+
];
|
|
88
|
+
var AFFIXES = ["str", "dex", "int", "hp", "crit", "haste"];
|
|
89
|
+
function rollItem(state, level) {
|
|
90
|
+
let r = rngNext(state);
|
|
91
|
+
state = r.state;
|
|
92
|
+
const total = RARITIES.reduce((a, b) => a + b.weight, 0);
|
|
93
|
+
let roll = r.value * total;
|
|
94
|
+
let rar = RARITIES[0];
|
|
95
|
+
for (const x of RARITIES) {
|
|
96
|
+
if (roll < x.weight) {
|
|
97
|
+
rar = x;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
roll -= x.weight;
|
|
101
|
+
}
|
|
102
|
+
r = rngNext(state);
|
|
103
|
+
state = r.state;
|
|
104
|
+
const base = BASES[Math.floor(r.value * BASES.length)];
|
|
105
|
+
const affixes = [];
|
|
106
|
+
for (let i = 0;i < rar.affixes; i++) {
|
|
107
|
+
r = rngNext(state);
|
|
108
|
+
state = r.state;
|
|
109
|
+
const stat = AFFIXES[Math.floor(r.value * AFFIXES.length)];
|
|
110
|
+
r = rngNext(state);
|
|
111
|
+
state = r.state;
|
|
112
|
+
const value = 1 + Math.floor(r.value * (level + 1));
|
|
113
|
+
affixes.push({ stat, value });
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
item: {
|
|
117
|
+
id: 0,
|
|
118
|
+
base: base.name,
|
|
119
|
+
slot: base.slot,
|
|
120
|
+
rarity: rar.name,
|
|
121
|
+
affixes
|
|
122
|
+
},
|
|
123
|
+
state
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ../shared/src/terrain.ts
|
|
127
|
+
function isSolid(t, cx, cy) {
|
|
128
|
+
if (cx < 0 || cx >= t.w)
|
|
129
|
+
return true;
|
|
130
|
+
if (cy < 0)
|
|
131
|
+
return false;
|
|
132
|
+
if (cy >= t.h)
|
|
133
|
+
return true;
|
|
134
|
+
return t.cells[cy * t.w + cx] === 1;
|
|
135
|
+
}
|
|
136
|
+
function makeStarterField(seed = 1337) {
|
|
137
|
+
const { w, h } = WORLD;
|
|
138
|
+
const cells = new Uint8Array(w * h);
|
|
139
|
+
for (let y = GROUND_TOP;y < h; y++)
|
|
140
|
+
for (let x = 0;x < w; x++)
|
|
141
|
+
cells[y * w + x] = 1;
|
|
142
|
+
let s = seed;
|
|
143
|
+
const next = () => {
|
|
144
|
+
const r = rngNext(s);
|
|
145
|
+
s = r.state;
|
|
146
|
+
return r.value;
|
|
147
|
+
};
|
|
148
|
+
for (let i = 0;i < 70; i++) {
|
|
149
|
+
const px = Math.floor(next() * (w - 16)) + 2;
|
|
150
|
+
const py = GROUND_TOP - 4 - Math.floor(next() * 18);
|
|
151
|
+
const len = 6 + Math.floor(next() * 12);
|
|
152
|
+
for (let x = px;x < Math.min(px + len, w); x++)
|
|
153
|
+
cells[py * w + x] = 1;
|
|
154
|
+
}
|
|
155
|
+
return { w, h, cells };
|
|
156
|
+
}
|
|
157
|
+
function makeTownTerrain() {
|
|
158
|
+
const w = TOWN.w;
|
|
159
|
+
const h = WORLD.h;
|
|
160
|
+
const cells = new Uint8Array(w * h);
|
|
161
|
+
const solid = (x, y) => {
|
|
162
|
+
if (x >= 0 && x < w && y >= 0 && y < h)
|
|
163
|
+
cells[y * w + x] = 1;
|
|
164
|
+
};
|
|
165
|
+
for (let y = GROUND_TOP;y < h; y++)
|
|
166
|
+
for (let x = 0;x < w; x++)
|
|
167
|
+
solid(x, y);
|
|
168
|
+
for (let y = GROUND_TOP - 12;y < GROUND_TOP; y++) {
|
|
169
|
+
solid(0, y);
|
|
170
|
+
solid(w - 1, y);
|
|
171
|
+
}
|
|
172
|
+
const daisY = GROUND_TOP - 3;
|
|
173
|
+
for (let x = Math.floor(w / 2) - 6;x <= Math.floor(w / 2) + 6; x++)
|
|
174
|
+
solid(x, daisY);
|
|
175
|
+
return { w, h, cells };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ../shared/src/physics.ts
|
|
179
|
+
function stepEntity(t, src, ctl, dt) {
|
|
180
|
+
const e = { ...src };
|
|
181
|
+
e.vx = ctl.moveX * e.speed;
|
|
182
|
+
if (e.vx > 0)
|
|
183
|
+
e.facing = 1;
|
|
184
|
+
else if (e.vx < 0)
|
|
185
|
+
e.facing = -1;
|
|
186
|
+
if (ctl.jump && e.onGround) {
|
|
187
|
+
e.vy = -PHYS.jump;
|
|
188
|
+
e.onGround = false;
|
|
189
|
+
}
|
|
190
|
+
let hitWall = false;
|
|
191
|
+
e.x += e.vx * dt;
|
|
192
|
+
const top = Math.floor(e.y);
|
|
193
|
+
const bot = Math.ceil(e.y + BOX.h) - 1;
|
|
194
|
+
if (e.vx > 0) {
|
|
195
|
+
const r2 = Math.ceil(e.x + BOX.w) - 1;
|
|
196
|
+
for (let cy = top;cy <= bot; cy++)
|
|
197
|
+
if (isSolid(t, r2, cy)) {
|
|
198
|
+
e.x = r2 - BOX.w;
|
|
199
|
+
e.vx = 0;
|
|
200
|
+
hitWall = true;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
} else if (e.vx < 0) {
|
|
204
|
+
const l2 = Math.floor(e.x);
|
|
205
|
+
for (let cy = top;cy <= bot; cy++)
|
|
206
|
+
if (isSolid(t, l2, cy)) {
|
|
207
|
+
e.x = l2 + 1;
|
|
208
|
+
e.vx = 0;
|
|
209
|
+
hitWall = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
e.vy += PHYS.grav * dt;
|
|
214
|
+
e.y += e.vy * dt;
|
|
215
|
+
const l = Math.floor(e.x);
|
|
216
|
+
const r = Math.ceil(e.x + BOX.w) - 1;
|
|
217
|
+
e.onGround = false;
|
|
218
|
+
if (e.vy > 0) {
|
|
219
|
+
const feet = Math.ceil(e.y + BOX.h) - 1;
|
|
220
|
+
for (let cx = l;cx <= r; cx++)
|
|
221
|
+
if (isSolid(t, cx, feet)) {
|
|
222
|
+
e.y = feet - BOX.h;
|
|
223
|
+
e.vy = 0;
|
|
224
|
+
e.onGround = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
} else if (e.vy < 0) {
|
|
228
|
+
const head = Math.floor(e.y);
|
|
229
|
+
for (let cx = l;cx <= r; cx++)
|
|
230
|
+
if (isSolid(t, cx, head)) {
|
|
231
|
+
e.y = head + 1;
|
|
232
|
+
e.vy = 0;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { e, hitWall };
|
|
237
|
+
}
|
|
238
|
+
// ../shared/src/progression.ts
|
|
239
|
+
function xpToNext(level) {
|
|
240
|
+
if (level >= PROGRESSION.levelCap)
|
|
241
|
+
return Infinity;
|
|
242
|
+
return 20 + level * level * 4;
|
|
243
|
+
}
|
|
244
|
+
function maxHpForLevel(level) {
|
|
245
|
+
return 80 + (level - 1) * 12;
|
|
246
|
+
}
|
|
247
|
+
function applyXp(p, amount) {
|
|
248
|
+
let level = p.level;
|
|
249
|
+
let xp = p.xp + amount;
|
|
250
|
+
let leveled = 0;
|
|
251
|
+
while (level < PROGRESSION.levelCap && xp >= xpToNext(level)) {
|
|
252
|
+
xp -= xpToNext(level);
|
|
253
|
+
level++;
|
|
254
|
+
leveled++;
|
|
255
|
+
}
|
|
256
|
+
if (level >= PROGRESSION.levelCap)
|
|
257
|
+
xp = 0;
|
|
258
|
+
return { progress: { level, xp, gold: p.gold }, leveled };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ../shared/src/player.ts
|
|
262
|
+
function spawnAvatar(x, y) {
|
|
263
|
+
return {
|
|
264
|
+
id: 1,
|
|
265
|
+
type: "player",
|
|
266
|
+
x,
|
|
267
|
+
y,
|
|
268
|
+
vx: 0,
|
|
269
|
+
vy: 0,
|
|
270
|
+
speed: PHYS.speed,
|
|
271
|
+
facing: 1,
|
|
272
|
+
onGround: false,
|
|
273
|
+
hp: maxHpForLevel(1),
|
|
274
|
+
maxHp: maxHpForLevel(1),
|
|
275
|
+
hurtT: 0,
|
|
276
|
+
attackT: 0
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function spawnPlayerState(zoneId, x, y, seed = 1) {
|
|
280
|
+
return {
|
|
281
|
+
avatar: spawnAvatar(x, y),
|
|
282
|
+
progress: { level: 1, xp: 0, gold: 0 },
|
|
283
|
+
inventory: [],
|
|
284
|
+
zoneId,
|
|
285
|
+
log: ["Welcome. Hunt the chasers (j to attack)."],
|
|
286
|
+
nextId: 1,
|
|
287
|
+
rngState: seed,
|
|
288
|
+
class: "warrior",
|
|
289
|
+
skillCooldowns: {}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// ../shared/src/projectile.ts
|
|
293
|
+
function projectileBox(p) {
|
|
294
|
+
return { x: p.x, y: p.y, w: PROJECTILE.w, h: PROJECTILE.h };
|
|
295
|
+
}
|
|
296
|
+
function spawnProjectile(id, owner, dir) {
|
|
297
|
+
return {
|
|
298
|
+
id,
|
|
299
|
+
x: dir === 1 ? owner.x + BOX.w : owner.x - PROJECTILE.w,
|
|
300
|
+
y: owner.y + Math.floor((BOX.h - PROJECTILE.h) / 2),
|
|
301
|
+
vx: dir * SHOOTER.projSpeed,
|
|
302
|
+
vy: 0,
|
|
303
|
+
life: SHOOTER.projLife,
|
|
304
|
+
damage: SHOOTER.projDamage,
|
|
305
|
+
ownerId: owner.id
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function stepProjectile(t, p, dt) {
|
|
309
|
+
const life = p.life - dt;
|
|
310
|
+
if (life <= 0)
|
|
311
|
+
return null;
|
|
312
|
+
const x = p.x + p.vx * dt;
|
|
313
|
+
const y = p.y + p.vy * dt;
|
|
314
|
+
if (isSolid(t, Math.floor(x), Math.floor(y)))
|
|
315
|
+
return null;
|
|
316
|
+
return { ...p, x, y, life };
|
|
317
|
+
}
|
|
318
|
+
// ../shared/src/protocol.ts
|
|
319
|
+
var PROTOCOL_VERSION = 1;
|
|
320
|
+
var textEncoder = new TextEncoder;
|
|
321
|
+
var textDecoder = new TextDecoder;
|
|
322
|
+
|
|
323
|
+
class Writer {
|
|
324
|
+
buf = new Uint8Array(64);
|
|
325
|
+
view = new DataView(this.buf.buffer);
|
|
326
|
+
pos = 0;
|
|
327
|
+
ensure(n) {
|
|
328
|
+
if (this.pos + n <= this.buf.length)
|
|
329
|
+
return;
|
|
330
|
+
let len = this.buf.length;
|
|
331
|
+
while (len < this.pos + n)
|
|
332
|
+
len *= 2;
|
|
333
|
+
const next = new Uint8Array(len);
|
|
334
|
+
next.set(this.buf);
|
|
335
|
+
this.buf = next;
|
|
336
|
+
this.view = new DataView(this.buf.buffer);
|
|
337
|
+
}
|
|
338
|
+
u8(v) {
|
|
339
|
+
this.ensure(1);
|
|
340
|
+
this.view.setUint8(this.pos, v);
|
|
341
|
+
this.pos += 1;
|
|
342
|
+
}
|
|
343
|
+
i8(v) {
|
|
344
|
+
this.ensure(1);
|
|
345
|
+
this.view.setInt8(this.pos, v);
|
|
346
|
+
this.pos += 1;
|
|
347
|
+
}
|
|
348
|
+
u16(v) {
|
|
349
|
+
this.ensure(2);
|
|
350
|
+
this.view.setUint16(this.pos, v);
|
|
351
|
+
this.pos += 2;
|
|
352
|
+
}
|
|
353
|
+
u32(v) {
|
|
354
|
+
this.ensure(4);
|
|
355
|
+
this.view.setUint32(this.pos, v);
|
|
356
|
+
this.pos += 4;
|
|
357
|
+
}
|
|
358
|
+
i32(v) {
|
|
359
|
+
this.ensure(4);
|
|
360
|
+
this.view.setInt32(this.pos, v);
|
|
361
|
+
this.pos += 4;
|
|
362
|
+
}
|
|
363
|
+
f64(v) {
|
|
364
|
+
this.ensure(8);
|
|
365
|
+
this.view.setFloat64(this.pos, v);
|
|
366
|
+
this.pos += 8;
|
|
367
|
+
}
|
|
368
|
+
bool(v) {
|
|
369
|
+
this.u8(v ? 1 : 0);
|
|
370
|
+
}
|
|
371
|
+
str(s) {
|
|
372
|
+
const bytes = textEncoder.encode(s);
|
|
373
|
+
this.u32(bytes.length);
|
|
374
|
+
this.ensure(bytes.length);
|
|
375
|
+
this.buf.set(bytes, this.pos);
|
|
376
|
+
this.pos += bytes.length;
|
|
377
|
+
}
|
|
378
|
+
finish() {
|
|
379
|
+
return this.buf.subarray(0, this.pos);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
class Reader {
|
|
384
|
+
buf;
|
|
385
|
+
view;
|
|
386
|
+
pos = 0;
|
|
387
|
+
constructor(buf) {
|
|
388
|
+
this.buf = buf;
|
|
389
|
+
this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
390
|
+
}
|
|
391
|
+
u8() {
|
|
392
|
+
const v = this.view.getUint8(this.pos);
|
|
393
|
+
this.pos += 1;
|
|
394
|
+
return v;
|
|
395
|
+
}
|
|
396
|
+
i8() {
|
|
397
|
+
const v = this.view.getInt8(this.pos);
|
|
398
|
+
this.pos += 1;
|
|
399
|
+
return v;
|
|
400
|
+
}
|
|
401
|
+
u16() {
|
|
402
|
+
const v = this.view.getUint16(this.pos);
|
|
403
|
+
this.pos += 2;
|
|
404
|
+
return v;
|
|
405
|
+
}
|
|
406
|
+
u32() {
|
|
407
|
+
const v = this.view.getUint32(this.pos);
|
|
408
|
+
this.pos += 4;
|
|
409
|
+
return v;
|
|
410
|
+
}
|
|
411
|
+
i32() {
|
|
412
|
+
const v = this.view.getInt32(this.pos);
|
|
413
|
+
this.pos += 4;
|
|
414
|
+
return v;
|
|
415
|
+
}
|
|
416
|
+
f64() {
|
|
417
|
+
const v = this.view.getFloat64(this.pos);
|
|
418
|
+
this.pos += 8;
|
|
419
|
+
return v;
|
|
420
|
+
}
|
|
421
|
+
bool() {
|
|
422
|
+
return this.u8() !== 0;
|
|
423
|
+
}
|
|
424
|
+
str() {
|
|
425
|
+
const len = this.u32();
|
|
426
|
+
const bytes = this.buf.subarray(this.pos, this.pos + len);
|
|
427
|
+
this.pos += len;
|
|
428
|
+
return textDecoder.decode(bytes);
|
|
429
|
+
}
|
|
430
|
+
remaining() {
|
|
431
|
+
return this.buf.byteLength - this.pos;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
var CLIENT_TAG = { hello: 1, input: 2, chat: 3 };
|
|
435
|
+
function encodeClientMessage(msg) {
|
|
436
|
+
const w = new Writer;
|
|
437
|
+
switch (msg.t) {
|
|
438
|
+
case "hello":
|
|
439
|
+
w.u8(CLIENT_TAG.hello);
|
|
440
|
+
w.str(msg.handle);
|
|
441
|
+
w.u16(msg.protocol);
|
|
442
|
+
break;
|
|
443
|
+
case "input":
|
|
444
|
+
w.u8(CLIENT_TAG.input);
|
|
445
|
+
w.f64(msg.x);
|
|
446
|
+
w.f64(msg.y);
|
|
447
|
+
w.f64(msg.vx);
|
|
448
|
+
w.f64(msg.vy);
|
|
449
|
+
w.i8(msg.facing);
|
|
450
|
+
w.bool(msg.onGround);
|
|
451
|
+
w.bool(msg.attack);
|
|
452
|
+
w.bool(msg.interact);
|
|
453
|
+
w.u8(msg.skill ?? 0);
|
|
454
|
+
break;
|
|
455
|
+
case "chat":
|
|
456
|
+
w.u8(CLIENT_TAG.chat);
|
|
457
|
+
w.str(msg.text);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
return w.finish();
|
|
461
|
+
}
|
|
462
|
+
var SERVER_TAG = { welcome: 1, snapshot: 2, chat: 3, reject: 4 };
|
|
463
|
+
var ENTITY_TYPES = ["player", "chaser", "shooter"];
|
|
464
|
+
var SLOTS = ["weapon", "armor", "accessory"];
|
|
465
|
+
var RARITIES2 = [
|
|
466
|
+
"common",
|
|
467
|
+
"uncommon",
|
|
468
|
+
"rare",
|
|
469
|
+
"epic",
|
|
470
|
+
"legendary"
|
|
471
|
+
];
|
|
472
|
+
function readAvatar(r) {
|
|
473
|
+
return {
|
|
474
|
+
sessionId: r.u32(),
|
|
475
|
+
handle: r.str(),
|
|
476
|
+
x: r.f64(),
|
|
477
|
+
y: r.f64(),
|
|
478
|
+
vx: r.f64(),
|
|
479
|
+
vy: r.f64(),
|
|
480
|
+
facing: r.i8(),
|
|
481
|
+
onGround: r.bool(),
|
|
482
|
+
hp: r.f64(),
|
|
483
|
+
maxHp: r.f64(),
|
|
484
|
+
hurtT: r.f64()
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function readMonster(r) {
|
|
488
|
+
return {
|
|
489
|
+
id: r.u32(),
|
|
490
|
+
type: ENTITY_TYPES[r.u8()],
|
|
491
|
+
x: r.f64(),
|
|
492
|
+
y: r.f64(),
|
|
493
|
+
vx: r.f64(),
|
|
494
|
+
vy: r.f64(),
|
|
495
|
+
facing: r.i8(),
|
|
496
|
+
onGround: r.bool(),
|
|
497
|
+
hp: r.f64(),
|
|
498
|
+
maxHp: r.f64(),
|
|
499
|
+
hurtT: r.f64()
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function readProjectile(r) {
|
|
503
|
+
return {
|
|
504
|
+
id: r.u32(),
|
|
505
|
+
x: r.f64(),
|
|
506
|
+
y: r.f64(),
|
|
507
|
+
vx: r.f64(),
|
|
508
|
+
vy: r.f64(),
|
|
509
|
+
life: r.f64(),
|
|
510
|
+
damage: r.f64(),
|
|
511
|
+
ownerId: r.u32()
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function readItem(r) {
|
|
515
|
+
const id = r.u32();
|
|
516
|
+
const base = r.str();
|
|
517
|
+
const slot = SLOTS[r.u8()];
|
|
518
|
+
const rarity = RARITIES2[r.u8()];
|
|
519
|
+
const n = r.u32();
|
|
520
|
+
const affixes = [];
|
|
521
|
+
for (let i = 0;i < n; i++)
|
|
522
|
+
affixes.push({ stat: r.str(), value: r.i32() });
|
|
523
|
+
return { id, base, slot, rarity, affixes };
|
|
524
|
+
}
|
|
525
|
+
function decodeServerMessage(buf) {
|
|
526
|
+
const r = new Reader(buf);
|
|
527
|
+
const tag = r.u8();
|
|
528
|
+
switch (tag) {
|
|
529
|
+
case SERVER_TAG.welcome:
|
|
530
|
+
return {
|
|
531
|
+
t: "welcome",
|
|
532
|
+
sessionId: r.u32(),
|
|
533
|
+
zoneId: r.str(),
|
|
534
|
+
tickRate: r.u16()
|
|
535
|
+
};
|
|
536
|
+
case SERVER_TAG.snapshot: {
|
|
537
|
+
const tick = r.u32();
|
|
538
|
+
const zoneId = r.str();
|
|
539
|
+
const avatars = [];
|
|
540
|
+
for (let i = r.u32();i > 0; i--)
|
|
541
|
+
avatars.push(readAvatar(r));
|
|
542
|
+
const monsters = [];
|
|
543
|
+
for (let i = r.u32();i > 0; i--)
|
|
544
|
+
monsters.push(readMonster(r));
|
|
545
|
+
const projectiles = [];
|
|
546
|
+
for (let i = r.u32();i > 0; i--)
|
|
547
|
+
projectiles.push(readProjectile(r));
|
|
548
|
+
const progress = {
|
|
549
|
+
level: r.u32(),
|
|
550
|
+
xp: r.u32(),
|
|
551
|
+
gold: r.u32()
|
|
552
|
+
};
|
|
553
|
+
const inventory = [];
|
|
554
|
+
for (let i = r.u32();i > 0; i--)
|
|
555
|
+
inventory.push(readItem(r));
|
|
556
|
+
const log = [];
|
|
557
|
+
for (let i = r.u32();i > 0; i--)
|
|
558
|
+
log.push(r.str());
|
|
559
|
+
return {
|
|
560
|
+
t: "snapshot",
|
|
561
|
+
tick,
|
|
562
|
+
zoneId,
|
|
563
|
+
avatars,
|
|
564
|
+
monsters,
|
|
565
|
+
projectiles,
|
|
566
|
+
progress,
|
|
567
|
+
inventory,
|
|
568
|
+
log
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
case SERVER_TAG.chat:
|
|
572
|
+
return { t: "chat", sessionId: r.u32(), handle: r.str(), text: r.str() };
|
|
573
|
+
case SERVER_TAG.reject:
|
|
574
|
+
return { t: "reject", reason: r.str() };
|
|
575
|
+
default:
|
|
576
|
+
throw new Error(`unknown server message tag ${tag}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// ../shared/src/skills.ts
|
|
580
|
+
var POWER_STRIKE = {
|
|
581
|
+
id: "power-strike",
|
|
582
|
+
name: "Power Strike",
|
|
583
|
+
unlockLevel: 1,
|
|
584
|
+
cooldown: 2.5,
|
|
585
|
+
damage: 20,
|
|
586
|
+
reach: 9
|
|
587
|
+
};
|
|
588
|
+
var WARRIOR_SKILLS = [POWER_STRIKE];
|
|
589
|
+
var SKILLS_BY_CLASS = {
|
|
590
|
+
warrior: WARRIOR_SKILLS
|
|
591
|
+
};
|
|
592
|
+
function skillForSlot(cls, slot) {
|
|
593
|
+
return SKILLS_BY_CLASS[cls]?.[slot - 1];
|
|
594
|
+
}
|
|
595
|
+
function skillUnlocked(skill, level) {
|
|
596
|
+
return level >= skill.unlockLevel;
|
|
597
|
+
}
|
|
598
|
+
function skillHitbox(e, skill) {
|
|
599
|
+
return {
|
|
600
|
+
x: e.facing === 1 ? e.x + BOX.w : e.x - skill.reach,
|
|
601
|
+
y: e.y,
|
|
602
|
+
w: skill.reach,
|
|
603
|
+
h: BOX.h
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ../shared/src/world.ts
|
|
608
|
+
function activeZone(world, zoneId) {
|
|
609
|
+
return world.zones[zoneId];
|
|
610
|
+
}
|
|
611
|
+
function spawnMonster(type, id, x, y, spawnIndex) {
|
|
612
|
+
const hp = type === "shooter" ? SHOOTER.hp : MONSTER.chaserHp;
|
|
613
|
+
const speed = type === "shooter" ? SHOOTER.speed : MONSTER.chaserSpeed;
|
|
614
|
+
return {
|
|
615
|
+
id,
|
|
616
|
+
type,
|
|
617
|
+
x,
|
|
618
|
+
y,
|
|
619
|
+
vx: 0,
|
|
620
|
+
vy: 0,
|
|
621
|
+
speed,
|
|
622
|
+
facing: 1,
|
|
623
|
+
onGround: false,
|
|
624
|
+
hp,
|
|
625
|
+
maxHp: hp,
|
|
626
|
+
hurtT: 0,
|
|
627
|
+
attackT: 0,
|
|
628
|
+
spawnIndex
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function makeFieldZone(id, seed = 1337) {
|
|
632
|
+
const terrain = makeStarterField(seed);
|
|
633
|
+
const spawns = [];
|
|
634
|
+
for (let i = 0;i < 8; i++) {
|
|
635
|
+
const x = 40 + i * 22;
|
|
636
|
+
const type = i % 3 === 2 ? "shooter" : "chaser";
|
|
637
|
+
spawns.push({ type, x, y: GROUND_TOP - BOX.h });
|
|
638
|
+
}
|
|
639
|
+
let mid = 2;
|
|
640
|
+
const monsters = spawns.map((s, i) => spawnMonster(s.type, mid++, s.x, s.y, i));
|
|
641
|
+
return {
|
|
642
|
+
id,
|
|
643
|
+
type: "field",
|
|
644
|
+
terrain,
|
|
645
|
+
monsters,
|
|
646
|
+
projectiles: [],
|
|
647
|
+
nextProjectileId: 1,
|
|
648
|
+
spawns,
|
|
649
|
+
respawns: [],
|
|
650
|
+
nextMonsterId: mid,
|
|
651
|
+
portals: [
|
|
652
|
+
{
|
|
653
|
+
x: 24,
|
|
654
|
+
y: GROUND_TOP - 7,
|
|
655
|
+
w: 4,
|
|
656
|
+
h: 7,
|
|
657
|
+
target: "town-01",
|
|
658
|
+
arrival: { x: 12, y: GROUND_TOP - BOX.h }
|
|
659
|
+
}
|
|
660
|
+
]
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function makeTownZone(id) {
|
|
664
|
+
return {
|
|
665
|
+
id,
|
|
666
|
+
type: "town",
|
|
667
|
+
terrain: makeTownTerrain(),
|
|
668
|
+
monsters: [],
|
|
669
|
+
projectiles: [],
|
|
670
|
+
nextProjectileId: 1,
|
|
671
|
+
spawns: [],
|
|
672
|
+
respawns: [],
|
|
673
|
+
nextMonsterId: 1,
|
|
674
|
+
portals: [
|
|
675
|
+
{
|
|
676
|
+
x: TOWN.w - 16,
|
|
677
|
+
y: GROUND_TOP - 7,
|
|
678
|
+
w: 4,
|
|
679
|
+
h: 7,
|
|
680
|
+
target: "field-01",
|
|
681
|
+
arrival: { x: SPAWN.x, y: SPAWN.y }
|
|
682
|
+
}
|
|
683
|
+
],
|
|
684
|
+
npcs: [
|
|
685
|
+
{
|
|
686
|
+
id: 1,
|
|
687
|
+
kind: "vendor",
|
|
688
|
+
name: "Merchant",
|
|
689
|
+
x: 32,
|
|
690
|
+
y: GROUND_TOP - BOX.h,
|
|
691
|
+
w: 4,
|
|
692
|
+
h: BOX.h
|
|
693
|
+
}
|
|
694
|
+
]
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ../shared/src/zone.ts
|
|
699
|
+
function clientStepAvatar(t, avatar, ctl, dtMs) {
|
|
700
|
+
const dt = Math.min(dtMs / 1000, PHYS.maxDt);
|
|
701
|
+
const e = stepEntity(t, avatar, ctl, dt).e;
|
|
702
|
+
e.attackT = Math.max(0, e.attackT - dt);
|
|
703
|
+
e.hurtT = Math.max(0, e.hurtT - dt);
|
|
704
|
+
return e;
|
|
705
|
+
}
|
|
706
|
+
function resolveAvatarIntent(src, intent, dt) {
|
|
707
|
+
let avatar = {
|
|
708
|
+
...src.avatar,
|
|
709
|
+
x: intent.x,
|
|
710
|
+
y: intent.y,
|
|
711
|
+
vx: intent.vx,
|
|
712
|
+
vy: intent.vy,
|
|
713
|
+
facing: intent.facing,
|
|
714
|
+
onGround: intent.onGround
|
|
715
|
+
};
|
|
716
|
+
avatar.attackT = Math.max(0, avatar.attackT - dt);
|
|
717
|
+
avatar.hurtT = Math.max(0, avatar.hurtT - dt);
|
|
718
|
+
const log = src.log.slice(-5);
|
|
719
|
+
const attacking = intent.attack && avatar.attackT <= 0;
|
|
720
|
+
if (attacking)
|
|
721
|
+
avatar = { ...avatar, attackT: COMBAT.attackCooldown };
|
|
722
|
+
let hb = attacking ? meleeHitbox(avatar) : null;
|
|
723
|
+
let damage = COMBAT.meleeDamage;
|
|
724
|
+
const skillCooldowns = {};
|
|
725
|
+
for (const [id, cd] of Object.entries(src.skillCooldowns ?? {}))
|
|
726
|
+
skillCooldowns[id] = Math.max(0, cd - dt);
|
|
727
|
+
if (intent.skill) {
|
|
728
|
+
const skill = skillForSlot(src.class ?? "warrior", intent.skill);
|
|
729
|
+
if (skill && skillUnlocked(skill, src.progress.level) && (skillCooldowns[skill.id] ?? 0) <= 0) {
|
|
730
|
+
skillCooldowns[skill.id] = skill.cooldown;
|
|
731
|
+
hb = skillHitbox(avatar, skill);
|
|
732
|
+
damage = skill.damage;
|
|
733
|
+
log.push(`${skill.name}!`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return { sa: { ...src, avatar, log, skillCooldowns }, hb, damage };
|
|
737
|
+
}
|
|
738
|
+
function nearestAvatar(avatars, mx) {
|
|
739
|
+
let best = -1;
|
|
740
|
+
let bestAdx = Infinity;
|
|
741
|
+
for (let i = 0;i < avatars.length; i++) {
|
|
742
|
+
const adx = Math.abs(avatars[i].avatar.x - mx);
|
|
743
|
+
if (adx < bestAdx) {
|
|
744
|
+
bestAdx = adx;
|
|
745
|
+
best = i;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return best;
|
|
749
|
+
}
|
|
750
|
+
function stepZone(state, intents, dtMs) {
|
|
751
|
+
const dt = Math.min(dtMs / 1000, PHYS.maxDt);
|
|
752
|
+
const zone = state.zone;
|
|
753
|
+
const t = zone.terrain;
|
|
754
|
+
const byId = new Map(intents.map((i) => [i.sessionId, i]));
|
|
755
|
+
const hitboxes = [];
|
|
756
|
+
const damages = [];
|
|
757
|
+
const avatars = state.avatars.map((src) => {
|
|
758
|
+
const intent = byId.get(src.sessionId);
|
|
759
|
+
if (!intent) {
|
|
760
|
+
const avatar = {
|
|
761
|
+
...src.avatar,
|
|
762
|
+
attackT: Math.max(0, src.avatar.attackT - dt),
|
|
763
|
+
hurtT: Math.max(0, src.avatar.hurtT - dt)
|
|
764
|
+
};
|
|
765
|
+
hitboxes.push(null);
|
|
766
|
+
damages.push(0);
|
|
767
|
+
return { ...src, avatar, log: src.log.slice(-5) };
|
|
768
|
+
}
|
|
769
|
+
const { sa, hb, damage } = resolveAvatarIntent(src, intent, dt);
|
|
770
|
+
hitboxes.push(hb);
|
|
771
|
+
damages.push(damage);
|
|
772
|
+
return sa;
|
|
773
|
+
});
|
|
774
|
+
const fired = [];
|
|
775
|
+
let nextProjectileId = zone.nextProjectileId;
|
|
776
|
+
let nextMonsterId = zone.nextMonsterId;
|
|
777
|
+
const respawns = [];
|
|
778
|
+
const monsters = [];
|
|
779
|
+
for (const m0 of zone.monsters) {
|
|
780
|
+
let m = { ...m0 };
|
|
781
|
+
m.hurtT = Math.max(0, m.hurtT - dt);
|
|
782
|
+
m.attackT = Math.max(0, m.attackT - dt);
|
|
783
|
+
const target = nearestAvatar(avatars, m.x);
|
|
784
|
+
const dx = target >= 0 ? avatars[target].avatar.x - m.x : 0;
|
|
785
|
+
const adx = Math.abs(dx);
|
|
786
|
+
const engaged = target >= 0 && m.type === "shooter" && adx < SHOOTER.aggro;
|
|
787
|
+
let moveX;
|
|
788
|
+
if (target >= 0 && m.type === "chaser" && adx < MONSTER.chaserAggro)
|
|
789
|
+
moveX = adx < MONSTER.chaserDeadzone ? 0 : dx > 0 ? 1 : -1;
|
|
790
|
+
else if (engaged)
|
|
791
|
+
moveX = adx < SHOOTER.keepDist ? dx > 0 ? -1 : 1 : 0;
|
|
792
|
+
else
|
|
793
|
+
moveX = m.facing;
|
|
794
|
+
const res = stepEntity(t, m, { moveX, jump: false }, dt);
|
|
795
|
+
m = res.e;
|
|
796
|
+
if (m.onGround && !engaged) {
|
|
797
|
+
const lead = moveX >= 0 ? Math.ceil(m.x + BOX.w) - 1 : Math.floor(m.x);
|
|
798
|
+
const footY = Math.ceil(m.y + BOX.h);
|
|
799
|
+
if (res.hitWall || !isSolid(t, lead, footY))
|
|
800
|
+
m.facing = m.facing === 1 ? -1 : 1;
|
|
801
|
+
}
|
|
802
|
+
if (engaged) {
|
|
803
|
+
const dir = dx >= 0 ? 1 : -1;
|
|
804
|
+
m.facing = dir;
|
|
805
|
+
if (m.attackT <= 0) {
|
|
806
|
+
fired.push(spawnProjectile(nextProjectileId++, m, dir));
|
|
807
|
+
m = { ...m, attackT: SHOOTER.fireCooldown };
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
let killer = -1;
|
|
811
|
+
for (let i = 0;i < avatars.length; i++) {
|
|
812
|
+
const hb = hitboxes[i];
|
|
813
|
+
if (hb && m.hurtT <= 0 && aabbOverlap(hb, entityBox(m))) {
|
|
814
|
+
m = { ...m, hp: m.hp - damages[i], hurtT: 0.6 };
|
|
815
|
+
killer = i;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (m.hp > 0) {
|
|
820
|
+
for (let i = 0;i < avatars.length; i++) {
|
|
821
|
+
const a = avatars[i].avatar;
|
|
822
|
+
if (a.hurtT <= 0 && aabbOverlap(entityBox(a), entityBox(m))) {
|
|
823
|
+
avatars[i] = {
|
|
824
|
+
...avatars[i],
|
|
825
|
+
avatar: {
|
|
826
|
+
...a,
|
|
827
|
+
hp: a.hp - MONSTER.contactDamage,
|
|
828
|
+
hurtT: 0.6
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (m.hp > 0) {
|
|
835
|
+
monsters.push(m);
|
|
836
|
+
} else {
|
|
837
|
+
const credited = killer >= 0 ? killer : 0;
|
|
838
|
+
avatars[credited] = grantKill(avatars[credited]);
|
|
839
|
+
if (m.spawnIndex !== undefined)
|
|
840
|
+
respawns.push({
|
|
841
|
+
spawnIndex: m.spawnIndex,
|
|
842
|
+
remaining: RESPAWN.delaySec
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
for (const r of zone.respawns) {
|
|
847
|
+
const remaining = r.remaining - dt;
|
|
848
|
+
if (remaining > 0) {
|
|
849
|
+
respawns.push({ ...r, remaining });
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const s = zone.spawns[r.spawnIndex];
|
|
853
|
+
monsters.push(spawnMonster(s.type, nextMonsterId++, s.x, s.y, r.spawnIndex));
|
|
854
|
+
}
|
|
855
|
+
const projectiles = [];
|
|
856
|
+
for (const pr0 of zone.projectiles) {
|
|
857
|
+
const pr = stepProjectile(t, pr0, dt);
|
|
858
|
+
if (!pr)
|
|
859
|
+
continue;
|
|
860
|
+
let consumed = false;
|
|
861
|
+
for (let i = 0;i < avatars.length; i++) {
|
|
862
|
+
const a = avatars[i].avatar;
|
|
863
|
+
if (a.hurtT <= 0 && aabbOverlap(projectileBox(pr), entityBox(a))) {
|
|
864
|
+
avatars[i] = {
|
|
865
|
+
...avatars[i],
|
|
866
|
+
avatar: { ...a, hp: a.hp - pr.damage, hurtT: COMBAT.iframes }
|
|
867
|
+
};
|
|
868
|
+
consumed = true;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (!consumed)
|
|
873
|
+
projectiles.push(pr);
|
|
874
|
+
}
|
|
875
|
+
projectiles.push(...fired);
|
|
876
|
+
const deaths = [];
|
|
877
|
+
for (let i = 0;i < avatars.length; i++) {
|
|
878
|
+
const a = avatars[i].avatar;
|
|
879
|
+
if (a.hp <= 0) {
|
|
880
|
+
deaths.push(avatars[i].sessionId);
|
|
881
|
+
avatars[i] = {
|
|
882
|
+
...avatars[i],
|
|
883
|
+
avatar: {
|
|
884
|
+
...a,
|
|
885
|
+
hp: a.maxHp,
|
|
886
|
+
x: SPAWN.x,
|
|
887
|
+
y: SPAWN.y,
|
|
888
|
+
vx: 0,
|
|
889
|
+
vy: 0,
|
|
890
|
+
hurtT: 1
|
|
891
|
+
},
|
|
892
|
+
log: [...avatars[i].log, "You fell. Respawned in safety."]
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const newZone = {
|
|
897
|
+
...zone,
|
|
898
|
+
monsters,
|
|
899
|
+
projectiles,
|
|
900
|
+
nextProjectileId,
|
|
901
|
+
respawns,
|
|
902
|
+
nextMonsterId
|
|
903
|
+
};
|
|
904
|
+
return { zone: newZone, avatars, tick: state.tick + 1, deaths };
|
|
905
|
+
}
|
|
906
|
+
function grantKill(sa) {
|
|
907
|
+
const ap = applyXp(sa.progress, XP_PER_KILL);
|
|
908
|
+
const log = [...sa.log];
|
|
909
|
+
let avatar = sa.avatar;
|
|
910
|
+
if (ap.leveled > 0) {
|
|
911
|
+
const mhp = maxHpForLevel(ap.progress.level);
|
|
912
|
+
avatar = { ...avatar, maxHp: mhp, hp: mhp };
|
|
913
|
+
log.push(`Level up! Now level ${ap.progress.level}.`);
|
|
914
|
+
}
|
|
915
|
+
const roll = rollItem(sa.rngState, ap.progress.level);
|
|
916
|
+
const item = { ...roll.item, id: sa.nextId };
|
|
917
|
+
log.push(`Looted ${item.rarity} ${item.base}.`);
|
|
918
|
+
return {
|
|
919
|
+
...sa,
|
|
920
|
+
avatar,
|
|
921
|
+
progress: ap.progress,
|
|
922
|
+
inventory: [...sa.inventory, item],
|
|
923
|
+
nextId: sa.nextId + 1,
|
|
924
|
+
rngState: roll.state,
|
|
925
|
+
log
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
// ../shared/src/sim.ts
|
|
929
|
+
function createGame(seed = 1) {
|
|
930
|
+
const field = makeFieldZone("field-01");
|
|
931
|
+
const town = makeTownZone("town-01");
|
|
932
|
+
const player = spawnPlayerState(field.id, SPAWN.x, SPAWN.y, seed);
|
|
933
|
+
return {
|
|
934
|
+
player,
|
|
935
|
+
world: { zones: { [field.id]: field, [town.id]: town }, tick: 0 }
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function step(game, input, dtMs) {
|
|
939
|
+
const dt = Math.min(dtMs / 1000, PHYS.maxDt);
|
|
940
|
+
const zone = game.world.zones[game.player.zoneId];
|
|
941
|
+
const t = zone.terrain;
|
|
942
|
+
if (input.interact) {
|
|
943
|
+
const here = entityBox(game.player.avatar);
|
|
944
|
+
const portal = zone.portals.find((p) => aabbOverlap(here, p));
|
|
945
|
+
if (portal) {
|
|
946
|
+
const avatar = {
|
|
947
|
+
...game.player.avatar,
|
|
948
|
+
x: portal.arrival.x,
|
|
949
|
+
y: portal.arrival.y,
|
|
950
|
+
vx: 0,
|
|
951
|
+
vy: 0,
|
|
952
|
+
onGround: false
|
|
953
|
+
};
|
|
954
|
+
const dest = game.world.zones[portal.target];
|
|
955
|
+
const log = [...game.player.log.slice(-5), `Entered the ${dest.type}.`];
|
|
956
|
+
const player2 = {
|
|
957
|
+
...game.player,
|
|
958
|
+
avatar,
|
|
959
|
+
zoneId: portal.target,
|
|
960
|
+
log
|
|
961
|
+
};
|
|
962
|
+
return { player: player2, world: { ...game.world, tick: game.world.tick + 1 } };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const predicted = stepEntity(t, game.player.avatar, { moveX: input.moveX, jump: input.jump }, dt).e;
|
|
966
|
+
const sa = {
|
|
967
|
+
sessionId: game.player.avatar.id,
|
|
968
|
+
handle: "",
|
|
969
|
+
avatar: game.player.avatar,
|
|
970
|
+
progress: game.player.progress,
|
|
971
|
+
inventory: game.player.inventory,
|
|
972
|
+
log: game.player.log,
|
|
973
|
+
nextId: game.player.nextId,
|
|
974
|
+
rngState: game.player.rngState,
|
|
975
|
+
class: game.player.class,
|
|
976
|
+
skillCooldowns: game.player.skillCooldowns
|
|
977
|
+
};
|
|
978
|
+
const intent = {
|
|
979
|
+
sessionId: sa.sessionId,
|
|
980
|
+
x: predicted.x,
|
|
981
|
+
y: predicted.y,
|
|
982
|
+
vx: predicted.vx,
|
|
983
|
+
vy: predicted.vy,
|
|
984
|
+
facing: predicted.facing,
|
|
985
|
+
onGround: predicted.onGround,
|
|
986
|
+
attack: input.attack,
|
|
987
|
+
skill: input.skill
|
|
988
|
+
};
|
|
989
|
+
const next = stepZone({ zone, avatars: [sa], tick: game.world.tick }, [intent], dtMs);
|
|
990
|
+
const out = next.avatars[0];
|
|
991
|
+
const player = {
|
|
992
|
+
avatar: out.avatar,
|
|
993
|
+
progress: out.progress,
|
|
994
|
+
inventory: out.inventory,
|
|
995
|
+
zoneId: game.player.zoneId,
|
|
996
|
+
log: out.log,
|
|
997
|
+
nextId: out.nextId,
|
|
998
|
+
rngState: out.rngState,
|
|
999
|
+
class: out.class,
|
|
1000
|
+
skillCooldowns: out.skillCooldowns
|
|
1001
|
+
};
|
|
1002
|
+
const world = {
|
|
1003
|
+
zones: { ...game.world.zones, [zone.id]: next.zone },
|
|
1004
|
+
tick: next.tick
|
|
1005
|
+
};
|
|
1006
|
+
return { player, world };
|
|
1007
|
+
}
|
|
1008
|
+
// ../shared/src/vendor.ts
|
|
1009
|
+
var RARITY_VALUE = {
|
|
1010
|
+
common: 5,
|
|
1011
|
+
uncommon: 12,
|
|
1012
|
+
rare: 30,
|
|
1013
|
+
epic: 75,
|
|
1014
|
+
legendary: 200
|
|
1015
|
+
};
|
|
1016
|
+
var AFFIX_VALUE = 2;
|
|
1017
|
+
function saleValue(item) {
|
|
1018
|
+
const affixTotal = item.affixes.reduce((a, x) => a + x.value, 0);
|
|
1019
|
+
return RARITY_VALUE[item.rarity] + AFFIX_VALUE * affixTotal;
|
|
1020
|
+
}
|
|
1021
|
+
function sellItem(progress, inventory, itemId) {
|
|
1022
|
+
const item = inventory.find((i) => i.id === itemId);
|
|
1023
|
+
if (!item)
|
|
1024
|
+
return { progress, inventory };
|
|
1025
|
+
return {
|
|
1026
|
+
progress: { ...progress, gold: progress.gold + saleValue(item) },
|
|
1027
|
+
inventory: inventory.filter((i) => i.id !== itemId)
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
// ../client/src/index.ts
|
|
1031
|
+
import { createCliRenderer } from "@opentui/core";
|
|
1032
|
+
|
|
1033
|
+
// ../client/src/chat.ts
|
|
1034
|
+
var MAX_LEN = CHAT_MAX_LEN;
|
|
1035
|
+
|
|
1036
|
+
class ChatInput {
|
|
1037
|
+
open = false;
|
|
1038
|
+
text = "";
|
|
1039
|
+
start() {
|
|
1040
|
+
this.open = true;
|
|
1041
|
+
this.text = "";
|
|
1042
|
+
}
|
|
1043
|
+
cancel() {
|
|
1044
|
+
this.open = false;
|
|
1045
|
+
this.text = "";
|
|
1046
|
+
}
|
|
1047
|
+
key(k) {
|
|
1048
|
+
if (!this.open)
|
|
1049
|
+
return { action: "none" };
|
|
1050
|
+
if (k.name === "return") {
|
|
1051
|
+
const text = this.text.trim();
|
|
1052
|
+
this.cancel();
|
|
1053
|
+
return text ? { action: "send", text } : { action: "cancel" };
|
|
1054
|
+
}
|
|
1055
|
+
if (k.name === "escape") {
|
|
1056
|
+
this.cancel();
|
|
1057
|
+
return { action: "cancel" };
|
|
1058
|
+
}
|
|
1059
|
+
if (k.name === "backspace") {
|
|
1060
|
+
this.text = this.text.slice(0, -1);
|
|
1061
|
+
return { action: "edit" };
|
|
1062
|
+
}
|
|
1063
|
+
if (k.ctrl || k.meta)
|
|
1064
|
+
return { action: "edit" };
|
|
1065
|
+
const ch = k.name === "space" ? " " : k.sequence ?? "";
|
|
1066
|
+
if (ch.length === 1 && ch >= " " && ch !== "\x7F" && this.text.length < MAX_LEN)
|
|
1067
|
+
this.text += ch;
|
|
1068
|
+
return { action: "edit" };
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ../client/src/hud.ts
|
|
1073
|
+
import {
|
|
1074
|
+
BoxRenderable,
|
|
1075
|
+
TextRenderable
|
|
1076
|
+
} from "@opentui/core";
|
|
1077
|
+
|
|
1078
|
+
// ../client/src/theme.ts
|
|
1079
|
+
import { RGBA } from "@opentui/core";
|
|
1080
|
+
var COLORS = {
|
|
1081
|
+
bg: RGBA.fromInts(16, 18, 26, 255),
|
|
1082
|
+
terrainFg: RGBA.fromInts(70, 82, 104, 255),
|
|
1083
|
+
terrainBg: RGBA.fromInts(34, 40, 54, 255),
|
|
1084
|
+
transparent: RGBA.fromInts(0, 0, 0, 0),
|
|
1085
|
+
hurt: RGBA.fromInts(255, 240, 120, 255),
|
|
1086
|
+
melee: RGBA.fromInts(255, 245, 200, 255),
|
|
1087
|
+
projectile: RGBA.fromInts(255, 120, 80, 255),
|
|
1088
|
+
portal: RGBA.fromInts(180, 130, 255, 255),
|
|
1089
|
+
vendor: RGBA.fromInts(255, 200, 90, 255),
|
|
1090
|
+
hud: RGBA.fromInts(232, 232, 238, 255),
|
|
1091
|
+
hudBg: RGBA.fromInts(8, 9, 13, 255),
|
|
1092
|
+
hp: RGBA.fromInts(90, 220, 120, 255),
|
|
1093
|
+
dim: RGBA.fromInts(150, 156, 168, 255),
|
|
1094
|
+
chat: RGBA.fromInts(120, 200, 235, 255),
|
|
1095
|
+
bubbleFg: RGBA.fromInts(236, 236, 242, 255),
|
|
1096
|
+
bubbleBorder: RGBA.fromInts(120, 200, 235, 255),
|
|
1097
|
+
bubbleBg: RGBA.fromInts(20, 24, 34, 255)
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// ../client/src/hud.ts
|
|
1101
|
+
var HINT = "move \u2190/\u2192 a/d jump \u2423/\u2191 attack j/x skill k interact e chat \u23CE quit q";
|
|
1102
|
+
var Z = 10;
|
|
1103
|
+
var CHAT_LINES = 4;
|
|
1104
|
+
function skillReadout(player2) {
|
|
1105
|
+
const segs = [];
|
|
1106
|
+
for (let slot = 1;; slot++) {
|
|
1107
|
+
const skill = skillForSlot(player2.class ?? "warrior", slot);
|
|
1108
|
+
if (!skill)
|
|
1109
|
+
break;
|
|
1110
|
+
let state;
|
|
1111
|
+
if (!skillUnlocked(skill, player2.progress.level))
|
|
1112
|
+
state = `L${skill.unlockLevel}`;
|
|
1113
|
+
else {
|
|
1114
|
+
const cd = player2.skillCooldowns?.[skill.id] ?? 0;
|
|
1115
|
+
state = cd > 0 ? `${cd.toFixed(1)}s` : "ready";
|
|
1116
|
+
}
|
|
1117
|
+
segs.push(`k ${skill.name}: ${state}`);
|
|
1118
|
+
}
|
|
1119
|
+
return segs.join(" ");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
class Hud {
|
|
1123
|
+
topBar;
|
|
1124
|
+
bottom;
|
|
1125
|
+
stats;
|
|
1126
|
+
alpha;
|
|
1127
|
+
meta;
|
|
1128
|
+
skills;
|
|
1129
|
+
log;
|
|
1130
|
+
chat;
|
|
1131
|
+
chatInput;
|
|
1132
|
+
constructor(ctx) {
|
|
1133
|
+
this.topBar = new BoxRenderable(ctx, {
|
|
1134
|
+
position: "absolute",
|
|
1135
|
+
top: 0,
|
|
1136
|
+
left: 0,
|
|
1137
|
+
right: 0,
|
|
1138
|
+
height: 1,
|
|
1139
|
+
flexDirection: "row",
|
|
1140
|
+
justifyContent: "space-between",
|
|
1141
|
+
backgroundColor: COLORS.hudBg,
|
|
1142
|
+
shouldFill: true,
|
|
1143
|
+
zIndex: Z
|
|
1144
|
+
});
|
|
1145
|
+
this.stats = new TextRenderable(ctx, {
|
|
1146
|
+
content: "",
|
|
1147
|
+
fg: COLORS.hud,
|
|
1148
|
+
bg: COLORS.hudBg
|
|
1149
|
+
});
|
|
1150
|
+
this.alpha = new TextRenderable(ctx, {
|
|
1151
|
+
content: "",
|
|
1152
|
+
fg: COLORS.vendor,
|
|
1153
|
+
bg: COLORS.hudBg
|
|
1154
|
+
});
|
|
1155
|
+
this.meta = new TextRenderable(ctx, {
|
|
1156
|
+
content: "",
|
|
1157
|
+
fg: COLORS.dim,
|
|
1158
|
+
bg: COLORS.hudBg
|
|
1159
|
+
});
|
|
1160
|
+
this.topBar.add(this.stats);
|
|
1161
|
+
this.topBar.add(this.alpha);
|
|
1162
|
+
this.topBar.add(this.meta);
|
|
1163
|
+
this.bottom = new BoxRenderable(ctx, {
|
|
1164
|
+
position: "absolute",
|
|
1165
|
+
bottom: 0,
|
|
1166
|
+
left: 1,
|
|
1167
|
+
flexDirection: "column",
|
|
1168
|
+
zIndex: Z
|
|
1169
|
+
});
|
|
1170
|
+
this.bottom.add(new TextRenderable(ctx, { content: HINT, fg: COLORS.dim, bg: COLORS.bg }));
|
|
1171
|
+
this.skills = new TextRenderable(ctx, {
|
|
1172
|
+
content: "",
|
|
1173
|
+
fg: COLORS.melee,
|
|
1174
|
+
bg: COLORS.bg
|
|
1175
|
+
});
|
|
1176
|
+
this.bottom.add(this.skills);
|
|
1177
|
+
this.log = new TextRenderable(ctx, {
|
|
1178
|
+
content: "",
|
|
1179
|
+
fg: COLORS.dim,
|
|
1180
|
+
bg: COLORS.bg
|
|
1181
|
+
});
|
|
1182
|
+
this.bottom.add(this.log);
|
|
1183
|
+
this.chat = new TextRenderable(ctx, {
|
|
1184
|
+
content: "",
|
|
1185
|
+
fg: COLORS.chat,
|
|
1186
|
+
bg: COLORS.bg
|
|
1187
|
+
});
|
|
1188
|
+
this.bottom.add(this.chat);
|
|
1189
|
+
this.chatInput = new TextRenderable(ctx, {
|
|
1190
|
+
content: "",
|
|
1191
|
+
fg: COLORS.melee,
|
|
1192
|
+
bg: COLORS.bg
|
|
1193
|
+
});
|
|
1194
|
+
this.bottom.add(this.chatInput);
|
|
1195
|
+
}
|
|
1196
|
+
attach(parent) {
|
|
1197
|
+
parent.add(this.topBar);
|
|
1198
|
+
parent.add(this.bottom);
|
|
1199
|
+
}
|
|
1200
|
+
showAlphaNotice() {
|
|
1201
|
+
this.alpha.content = " \u26A0 ALPHA \xB7 progress resets when the server restarts ";
|
|
1202
|
+
}
|
|
1203
|
+
update(game, fps) {
|
|
1204
|
+
const { player: player2 } = game;
|
|
1205
|
+
const p = player2.avatar;
|
|
1206
|
+
const zone2 = activeZone(game.world, player2.zoneId);
|
|
1207
|
+
const hpPct = Math.max(0, Math.round(p.hp / p.maxHp * 100));
|
|
1208
|
+
this.stats.content = ` L${player2.progress.level} HP ${Math.max(0, Math.round(p.hp))}/${p.maxHp} (${hpPct}%) XP ${player2.progress.xp} Gold ${player2.progress.gold} Items ${player2.inventory.length} `;
|
|
1209
|
+
this.meta.content = `FPS ${fps} monsters ${zone2.monsters.length} `;
|
|
1210
|
+
this.skills.content = skillReadout(player2);
|
|
1211
|
+
this.log.content = player2.log.slice(-3).join(`
|
|
1212
|
+
`);
|
|
1213
|
+
}
|
|
1214
|
+
updateChat(lines, open, draft) {
|
|
1215
|
+
this.chat.content = lines.slice(-CHAT_LINES).join(`
|
|
1216
|
+
`);
|
|
1217
|
+
this.chatInput.content = open ? `say> ${draft}\u2588` : "";
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ../client/src/input.ts
|
|
1222
|
+
var HELD_MS = 220;
|
|
1223
|
+
function actionFor(name) {
|
|
1224
|
+
switch (name) {
|
|
1225
|
+
case "left":
|
|
1226
|
+
case "a":
|
|
1227
|
+
return "left";
|
|
1228
|
+
case "right":
|
|
1229
|
+
case "d":
|
|
1230
|
+
return "right";
|
|
1231
|
+
case "up":
|
|
1232
|
+
case "space":
|
|
1233
|
+
return "jump";
|
|
1234
|
+
case "j":
|
|
1235
|
+
case "x":
|
|
1236
|
+
return "attack";
|
|
1237
|
+
case "e":
|
|
1238
|
+
return "interact";
|
|
1239
|
+
case "k":
|
|
1240
|
+
return "skill1";
|
|
1241
|
+
default:
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
class InputState {
|
|
1247
|
+
held = new Set;
|
|
1248
|
+
seen = new Map;
|
|
1249
|
+
releaseCapable = false;
|
|
1250
|
+
press(name, now) {
|
|
1251
|
+
const a = actionFor(name);
|
|
1252
|
+
if (!a)
|
|
1253
|
+
return;
|
|
1254
|
+
this.held.add(a);
|
|
1255
|
+
this.seen.set(a, now);
|
|
1256
|
+
}
|
|
1257
|
+
release(name) {
|
|
1258
|
+
this.releaseCapable = true;
|
|
1259
|
+
const a = actionFor(name);
|
|
1260
|
+
if (a)
|
|
1261
|
+
this.held.delete(a);
|
|
1262
|
+
}
|
|
1263
|
+
clear() {
|
|
1264
|
+
this.held.clear();
|
|
1265
|
+
}
|
|
1266
|
+
poll(now) {
|
|
1267
|
+
if (!this.releaseCapable) {
|
|
1268
|
+
for (const a of [...this.held])
|
|
1269
|
+
if (now - (this.seen.get(a) ?? 0) > HELD_MS)
|
|
1270
|
+
this.held.delete(a);
|
|
1271
|
+
}
|
|
1272
|
+
const moveX = (this.held.has("right") ? 1 : 0) - (this.held.has("left") ? 1 : 0);
|
|
1273
|
+
return {
|
|
1274
|
+
moveX,
|
|
1275
|
+
jump: this.held.has("jump"),
|
|
1276
|
+
attack: this.held.has("attack"),
|
|
1277
|
+
interact: this.held.has("interact"),
|
|
1278
|
+
skill: this.held.has("skill1") ? 1 : undefined
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ../client/src/bubble.ts
|
|
1284
|
+
function bubbleTtl(len) {
|
|
1285
|
+
return Math.max(3, Math.min(7, 2 + 0.05 * len));
|
|
1286
|
+
}
|
|
1287
|
+
var BUBBLE_COLS = 22;
|
|
1288
|
+
function layoutBubble(text, maxCols = BUBBLE_COLS) {
|
|
1289
|
+
const lines = [];
|
|
1290
|
+
let cur = "";
|
|
1291
|
+
for (const raw of text.split(/\s+/).filter(Boolean)) {
|
|
1292
|
+
let word = raw;
|
|
1293
|
+
while (word.length > maxCols) {
|
|
1294
|
+
if (cur) {
|
|
1295
|
+
lines.push(cur);
|
|
1296
|
+
cur = "";
|
|
1297
|
+
}
|
|
1298
|
+
lines.push(word.slice(0, maxCols));
|
|
1299
|
+
word = word.slice(maxCols);
|
|
1300
|
+
}
|
|
1301
|
+
if (!cur)
|
|
1302
|
+
cur = word;
|
|
1303
|
+
else if (cur.length + 1 + word.length <= maxCols)
|
|
1304
|
+
cur += ` ${word}`;
|
|
1305
|
+
else {
|
|
1306
|
+
lines.push(cur);
|
|
1307
|
+
cur = word;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (cur)
|
|
1311
|
+
lines.push(cur);
|
|
1312
|
+
return lines.length ? lines : [""];
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// ../client/src/interp.ts
|
|
1316
|
+
var INTERP_DELAY_MS = 100;
|
|
1317
|
+
function lerp(a, b, t) {
|
|
1318
|
+
return a + (b - a) * t;
|
|
1319
|
+
}
|
|
1320
|
+
function lerpSnapshot(older, newer, alpha) {
|
|
1321
|
+
const prevAvatar = new Map(older.avatars.map((a) => [a.sessionId, a]));
|
|
1322
|
+
const avatars = newer.avatars.map((a) => {
|
|
1323
|
+
const prev = prevAvatar.get(a.sessionId);
|
|
1324
|
+
if (!prev)
|
|
1325
|
+
return a;
|
|
1326
|
+
return { ...a, x: lerp(prev.x, a.x, alpha), y: lerp(prev.y, a.y, alpha) };
|
|
1327
|
+
});
|
|
1328
|
+
const prevMonster = new Map(older.monsters.map((m) => [m.id, m]));
|
|
1329
|
+
const monsters = newer.monsters.map((m) => {
|
|
1330
|
+
const prev = prevMonster.get(m.id);
|
|
1331
|
+
if (!prev)
|
|
1332
|
+
return m;
|
|
1333
|
+
return { ...m, x: lerp(prev.x, m.x, alpha), y: lerp(prev.y, m.y, alpha) };
|
|
1334
|
+
});
|
|
1335
|
+
return { ...newer, avatars, monsters };
|
|
1336
|
+
}
|
|
1337
|
+
var MAX_HISTORY_MS = 1000;
|
|
1338
|
+
|
|
1339
|
+
class SnapshotBuffer {
|
|
1340
|
+
frames = [];
|
|
1341
|
+
get size() {
|
|
1342
|
+
return this.frames.length;
|
|
1343
|
+
}
|
|
1344
|
+
push(snap, recvTimeMs) {
|
|
1345
|
+
this.frames.push({ snap, t: recvTimeMs });
|
|
1346
|
+
const cutoff = recvTimeMs - MAX_HISTORY_MS;
|
|
1347
|
+
let keepFrom = 0;
|
|
1348
|
+
while (keepFrom < this.frames.length - 2 && this.frames[keepFrom].t < cutoff)
|
|
1349
|
+
keepFrom++;
|
|
1350
|
+
if (keepFrom > 0)
|
|
1351
|
+
this.frames = this.frames.slice(keepFrom);
|
|
1352
|
+
}
|
|
1353
|
+
sample(renderTimeMs) {
|
|
1354
|
+
const frames = this.frames;
|
|
1355
|
+
if (frames.length === 0)
|
|
1356
|
+
return null;
|
|
1357
|
+
if (renderTimeMs <= frames[0].t)
|
|
1358
|
+
return frames[0].snap;
|
|
1359
|
+
const last = frames[frames.length - 1];
|
|
1360
|
+
if (renderTimeMs >= last.t)
|
|
1361
|
+
return last.snap;
|
|
1362
|
+
for (let i = 0;i < frames.length - 1; i++) {
|
|
1363
|
+
const a = frames[i];
|
|
1364
|
+
const b = frames[i + 1];
|
|
1365
|
+
if (renderTimeMs >= a.t && renderTimeMs < b.t) {
|
|
1366
|
+
const span = b.t - a.t;
|
|
1367
|
+
const alpha = span > 0 ? (renderTimeMs - a.t) / span : 1;
|
|
1368
|
+
return lerpSnapshot(a.snap, b.snap, alpha);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return last.snap;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// ../client/src/net.ts
|
|
1376
|
+
var MAX_CHAT_LOG = 100;
|
|
1377
|
+
|
|
1378
|
+
class NetClient {
|
|
1379
|
+
onReject;
|
|
1380
|
+
ws;
|
|
1381
|
+
buffer = new SnapshotBuffer;
|
|
1382
|
+
sessionId = 0;
|
|
1383
|
+
zoneId = "";
|
|
1384
|
+
tickRate = 20;
|
|
1385
|
+
ready = false;
|
|
1386
|
+
latest = null;
|
|
1387
|
+
chatLog = [];
|
|
1388
|
+
bubbles = new Map;
|
|
1389
|
+
rejected = null;
|
|
1390
|
+
constructor(url, handle, onReject = () => {}) {
|
|
1391
|
+
this.onReject = onReject;
|
|
1392
|
+
this.ws = new WebSocket(url);
|
|
1393
|
+
this.ws.binaryType = "arraybuffer";
|
|
1394
|
+
this.ws.onopen = () => {
|
|
1395
|
+
this.ws.send(encodeClientMessage({ t: "hello", handle, protocol: PROTOCOL_VERSION }));
|
|
1396
|
+
};
|
|
1397
|
+
this.ws.onmessage = (ev) => {
|
|
1398
|
+
const msg = decodeServerMessage(new Uint8Array(ev.data));
|
|
1399
|
+
this.ingest(msg, performance.now());
|
|
1400
|
+
};
|
|
1401
|
+
this.ws.onerror = () => {};
|
|
1402
|
+
}
|
|
1403
|
+
ingest(msg, recvTimeMs) {
|
|
1404
|
+
if (msg.t === "welcome") {
|
|
1405
|
+
this.sessionId = msg.sessionId;
|
|
1406
|
+
this.zoneId = msg.zoneId;
|
|
1407
|
+
this.tickRate = msg.tickRate;
|
|
1408
|
+
this.ready = true;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (msg.t === "reject") {
|
|
1412
|
+
this.rejected = msg.reason;
|
|
1413
|
+
this.onReject(msg.reason);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (msg.t === "chat") {
|
|
1417
|
+
this.chatLog.push(`${msg.handle}: ${msg.text}`);
|
|
1418
|
+
if (this.chatLog.length > MAX_CHAT_LOG)
|
|
1419
|
+
this.chatLog.splice(0, this.chatLog.length - MAX_CHAT_LOG);
|
|
1420
|
+
this.bubbles.set(msg.sessionId, {
|
|
1421
|
+
text: msg.text,
|
|
1422
|
+
ttl: bubbleTtl(msg.text.length)
|
|
1423
|
+
});
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (msg.zoneId !== this.zoneId) {
|
|
1427
|
+
this.zoneId = msg.zoneId;
|
|
1428
|
+
this.buffer = new SnapshotBuffer;
|
|
1429
|
+
}
|
|
1430
|
+
this.latest = msg;
|
|
1431
|
+
this.buffer.push(msg, recvTimeMs);
|
|
1432
|
+
}
|
|
1433
|
+
sample(nowMs) {
|
|
1434
|
+
return this.buffer.sample(nowMs - INTERP_DELAY_MS);
|
|
1435
|
+
}
|
|
1436
|
+
decayBubbles(dtSec) {
|
|
1437
|
+
for (const [id, b] of this.bubbles) {
|
|
1438
|
+
b.ttl -= dtSec;
|
|
1439
|
+
if (b.ttl <= 0)
|
|
1440
|
+
this.bubbles.delete(id);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
send(msg) {
|
|
1444
|
+
if (this.ready && this.ws.readyState === WebSocket.OPEN)
|
|
1445
|
+
this.ws.send(encodeClientMessage(msg));
|
|
1446
|
+
}
|
|
1447
|
+
close() {
|
|
1448
|
+
try {
|
|
1449
|
+
this.ws.close();
|
|
1450
|
+
} catch {}
|
|
1451
|
+
}
|
|
1452
|
+
ownAvatar() {
|
|
1453
|
+
return this.latest?.avatars.find((a) => a.sessionId === this.sessionId);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function avatarEntity(a) {
|
|
1457
|
+
return {
|
|
1458
|
+
id: a.sessionId,
|
|
1459
|
+
type: "player",
|
|
1460
|
+
name: a.handle,
|
|
1461
|
+
x: a.x,
|
|
1462
|
+
y: a.y,
|
|
1463
|
+
vx: a.vx,
|
|
1464
|
+
vy: a.vy,
|
|
1465
|
+
speed: 0,
|
|
1466
|
+
facing: a.facing,
|
|
1467
|
+
onGround: a.onGround,
|
|
1468
|
+
hp: a.hp,
|
|
1469
|
+
maxHp: a.maxHp,
|
|
1470
|
+
hurtT: a.hurtT,
|
|
1471
|
+
attackT: 0
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
function monsterEntity(m) {
|
|
1475
|
+
return {
|
|
1476
|
+
id: m.id,
|
|
1477
|
+
type: m.type,
|
|
1478
|
+
x: m.x,
|
|
1479
|
+
y: m.y,
|
|
1480
|
+
vx: m.vx,
|
|
1481
|
+
vy: m.vy,
|
|
1482
|
+
speed: 0,
|
|
1483
|
+
facing: m.facing,
|
|
1484
|
+
onGround: m.onGround,
|
|
1485
|
+
hp: m.hp,
|
|
1486
|
+
maxHp: m.maxHp,
|
|
1487
|
+
hurtT: m.hurtT,
|
|
1488
|
+
attackT: 0
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function snapshotToGame(field, predicted, ownSessionId, snapshot, localSkillCooldowns, bubbles = new Map) {
|
|
1492
|
+
const monsters = snapshot ? snapshot.monsters.map(monsterEntity) : [];
|
|
1493
|
+
const projectiles = snapshot ? snapshot.projectiles : [];
|
|
1494
|
+
const others = snapshot ? snapshot.avatars.filter((a) => a.sessionId !== ownSessionId).map((a) => {
|
|
1495
|
+
const e = avatarEntity(a);
|
|
1496
|
+
const bubble = bubbles.get(a.sessionId)?.text;
|
|
1497
|
+
if (bubble)
|
|
1498
|
+
e.bubble = bubble;
|
|
1499
|
+
return e;
|
|
1500
|
+
}) : [];
|
|
1501
|
+
const ownBubble = bubbles.get(ownSessionId)?.text;
|
|
1502
|
+
const avatar = ownBubble ? { ...predicted, bubble: ownBubble } : predicted;
|
|
1503
|
+
const progress = snapshot?.progress ?? { level: 1, xp: 0, gold: 0 };
|
|
1504
|
+
const inventory = snapshot?.inventory ?? [];
|
|
1505
|
+
const log = snapshot?.log ?? ["Connecting\u2026"];
|
|
1506
|
+
const zone2 = { ...field, monsters, projectiles };
|
|
1507
|
+
const player2 = {
|
|
1508
|
+
avatar,
|
|
1509
|
+
progress,
|
|
1510
|
+
inventory,
|
|
1511
|
+
zoneId: field.id,
|
|
1512
|
+
log,
|
|
1513
|
+
nextId: 0,
|
|
1514
|
+
rngState: 0,
|
|
1515
|
+
class: "warrior",
|
|
1516
|
+
skillCooldowns: localSkillCooldowns
|
|
1517
|
+
};
|
|
1518
|
+
return {
|
|
1519
|
+
player: player2,
|
|
1520
|
+
world: { zones: { [field.id]: zone2 }, tick: snapshot?.tick ?? 0 },
|
|
1521
|
+
others
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ../client/src/playfield.ts
|
|
1526
|
+
import {
|
|
1527
|
+
Renderable
|
|
1528
|
+
} from "@opentui/core";
|
|
1529
|
+
|
|
1530
|
+
// ../client/src/camera.ts
|
|
1531
|
+
var CAMERA = {
|
|
1532
|
+
bandWidthFrac: 1 / 3,
|
|
1533
|
+
bandHeight: 14,
|
|
1534
|
+
snapDeltaCells: 8
|
|
1535
|
+
};
|
|
1536
|
+
var initCameraState = () => ({
|
|
1537
|
+
cam: null,
|
|
1538
|
+
center: null,
|
|
1539
|
+
zoneId: null
|
|
1540
|
+
});
|
|
1541
|
+
function stepCamera(state, zoneId, avatarX, avatarY, view) {
|
|
1542
|
+
const { sw, sh, ww, wh } = view;
|
|
1543
|
+
const cx = avatarX + BOX.w / 2;
|
|
1544
|
+
const cy = avatarY + BOX.h / 2;
|
|
1545
|
+
const clampX = (x) => Math.max(0, Math.min(x, Math.max(0, ww - sw)));
|
|
1546
|
+
const clampY = (y) => Math.max(0, Math.min(y, Math.max(0, wh - sh)));
|
|
1547
|
+
const teleported = state.center !== null && (Math.abs(cx - state.center.x) > CAMERA.snapDeltaCells || Math.abs(cy - state.center.y) > CAMERA.snapDeltaCells);
|
|
1548
|
+
let cam;
|
|
1549
|
+
if (state.cam === null || zoneId !== state.zoneId || teleported) {
|
|
1550
|
+
cam = { x: clampX(cx - sw / 2), y: clampY(cy - sh / 2) };
|
|
1551
|
+
} else {
|
|
1552
|
+
const bandW = sw * CAMERA.bandWidthFrac;
|
|
1553
|
+
const bandH = Math.min(CAMERA.bandHeight, sh * 0.7);
|
|
1554
|
+
let camX = state.cam.x;
|
|
1555
|
+
let camY = state.cam.y;
|
|
1556
|
+
const screenX = cx - camX;
|
|
1557
|
+
const screenY = cy - camY;
|
|
1558
|
+
if (screenX < (sw - bandW) / 2)
|
|
1559
|
+
camX = cx - (sw - bandW) / 2;
|
|
1560
|
+
else if (screenX > (sw + bandW) / 2)
|
|
1561
|
+
camX = cx - (sw + bandW) / 2;
|
|
1562
|
+
if (screenY < (sh - bandH) / 2)
|
|
1563
|
+
camY = cy - (sh - bandH) / 2;
|
|
1564
|
+
else if (screenY > (sh + bandH) / 2)
|
|
1565
|
+
camY = cy - (sh + bandH) / 2;
|
|
1566
|
+
cam = { x: clampX(camX), y: clampY(camY) };
|
|
1567
|
+
}
|
|
1568
|
+
return { cam, center: { x: cx, y: cy }, zoneId };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// ../client/src/sprites/sprite.ts
|
|
1572
|
+
var SENTINEL = "\xB7";
|
|
1573
|
+
var MIRROR = {
|
|
1574
|
+
"(": ")",
|
|
1575
|
+
")": "(",
|
|
1576
|
+
"[": "]",
|
|
1577
|
+
"]": "[",
|
|
1578
|
+
"{": "}",
|
|
1579
|
+
"}": "{",
|
|
1580
|
+
"<": ">",
|
|
1581
|
+
">": "<",
|
|
1582
|
+
"/": "\\",
|
|
1583
|
+
"\\": "/",
|
|
1584
|
+
"`": "'",
|
|
1585
|
+
"'": "`",
|
|
1586
|
+
"\u258C": "\u2590",
|
|
1587
|
+
"\u2590": "\u258C",
|
|
1588
|
+
"\u2598": "\u259D",
|
|
1589
|
+
"\u259D": "\u2598",
|
|
1590
|
+
"\u2596": "\u2597",
|
|
1591
|
+
"\u2597": "\u2596",
|
|
1592
|
+
"\u259B": "\u259C",
|
|
1593
|
+
"\u259C": "\u259B",
|
|
1594
|
+
"\u2599": "\u259F",
|
|
1595
|
+
"\u259F": "\u2599",
|
|
1596
|
+
"\u259A": "\u259E",
|
|
1597
|
+
"\u259E": "\u259A"
|
|
1598
|
+
};
|
|
1599
|
+
function splitTrimPad(art) {
|
|
1600
|
+
const lines = art.split(`
|
|
1601
|
+
`);
|
|
1602
|
+
while (lines.length > 0 && lines[0].trim() === "")
|
|
1603
|
+
lines.shift();
|
|
1604
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "")
|
|
1605
|
+
lines.pop();
|
|
1606
|
+
const width = lines.reduce((w, l) => Math.max(w, l.length), 0);
|
|
1607
|
+
return lines.map((l) => l.padEnd(width, " "));
|
|
1608
|
+
}
|
|
1609
|
+
function mirrorGlyphs(rows) {
|
|
1610
|
+
return rows.map((row) => {
|
|
1611
|
+
let out = "";
|
|
1612
|
+
for (let i = row.length - 1;i >= 0; i--)
|
|
1613
|
+
out += MIRROR[row[i]] ?? row[i];
|
|
1614
|
+
return out;
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
function reverseRows(rows) {
|
|
1618
|
+
return rows.map((row) => {
|
|
1619
|
+
let out = "";
|
|
1620
|
+
for (let i = row.length - 1;i >= 0; i--)
|
|
1621
|
+
out += row[i];
|
|
1622
|
+
return out;
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
class Sprite {
|
|
1627
|
+
w;
|
|
1628
|
+
h;
|
|
1629
|
+
glyphRight;
|
|
1630
|
+
glyphLeft;
|
|
1631
|
+
colorRight;
|
|
1632
|
+
colorLeft;
|
|
1633
|
+
constructor(glyph, opts) {
|
|
1634
|
+
const { defaultKey } = opts;
|
|
1635
|
+
if (defaultKey.length !== 1)
|
|
1636
|
+
throw new Error(`Sprite defaultKey must be a single char, got "${defaultKey}"`);
|
|
1637
|
+
const glyphRows = splitTrimPad(glyph).map((r) => r.replaceAll(SENTINEL, " "));
|
|
1638
|
+
this.h = glyphRows.length;
|
|
1639
|
+
this.w = glyphRows.length > 0 ? glyphRows[0].length : 0;
|
|
1640
|
+
let colorRows;
|
|
1641
|
+
if (opts.colors === undefined) {
|
|
1642
|
+
colorRows = glyphRows.map((r) => defaultKey.repeat(r.length));
|
|
1643
|
+
} else {
|
|
1644
|
+
const parsed = splitTrimPad(opts.colors);
|
|
1645
|
+
const cw = parsed.length > 0 ? parsed[0].length : 0;
|
|
1646
|
+
if (parsed.length !== this.h || cw !== this.w)
|
|
1647
|
+
throw new Error(`Sprite colour grid (${cw}x${parsed.length}) must match glyph grid (${this.w}x${this.h})`);
|
|
1648
|
+
colorRows = parsed.map((r) => Array.from(r, (c) => c === SENTINEL || c === " " ? defaultKey : c).join(""));
|
|
1649
|
+
}
|
|
1650
|
+
this.glyphRight = glyphRows;
|
|
1651
|
+
this.glyphLeft = mirrorGlyphs(glyphRows);
|
|
1652
|
+
this.colorRight = colorRows;
|
|
1653
|
+
this.colorLeft = reverseRows(colorRows);
|
|
1654
|
+
}
|
|
1655
|
+
rows(facing = 1) {
|
|
1656
|
+
return facing === 1 ? this.glyphRight : this.glyphLeft;
|
|
1657
|
+
}
|
|
1658
|
+
colorKeys(facing = 1) {
|
|
1659
|
+
return facing === 1 ? this.colorRight : this.colorLeft;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ../client/src/sprites/chaser.ts
|
|
1664
|
+
var GLYPH = `
|
|
1665
|
+
\u259A\xB7\u259F\u2599\xB7\u259E\xB7
|
|
1666
|
+
\u259F\u2588\u2588\u2588\u2588\u2599\xB7
|
|
1667
|
+
\u259E\u259B\u259B\u259B\u259B\u258C\xB7
|
|
1668
|
+
\u2590\u259F\u259F\u259F\u259F\u2596\xB7
|
|
1669
|
+
\u259E\xB7\xB7\xB7\xB7\u259A\xB7`;
|
|
1670
|
+
var COLORS2 = `
|
|
1671
|
+
\xB7\xB7\xB7\xB7\xB7\xB7\xB7
|
|
1672
|
+
\xB7g\xB7\xB7g\xB7\xB7
|
|
1673
|
+
\xB7\xB7\xB7\xB7\xB7\xB7\xB7
|
|
1674
|
+
\xB7\xB7\xB7\xB7\xB7\xB7\xB7
|
|
1675
|
+
\xB7\xB7\xB7\xB7\xB7\xB7\xB7`;
|
|
1676
|
+
var chaser = new Sprite(GLYPH, { defaultKey: "m", colors: COLORS2 });
|
|
1677
|
+
|
|
1678
|
+
// ../client/src/sprites/merchant.ts
|
|
1679
|
+
var GLYPH2 = `
|
|
1680
|
+
\xB7\xB7\u259F\u2599\xB7\xB7
|
|
1681
|
+
\xB7\u259F\u2588\u2588\u2599\xB7
|
|
1682
|
+
\u259F\u259B\u2588\u2588\u259C\u2599
|
|
1683
|
+
\u2588\u2588\u2588\u2588\u2588\u2588
|
|
1684
|
+
\u259D\u2588\u2588\u2588\u2588\u2598`;
|
|
1685
|
+
var COLORS3 = `
|
|
1686
|
+
\xB7\xB7oo\xB7\xB7
|
|
1687
|
+
\xB7oooo\xB7
|
|
1688
|
+
oooooo
|
|
1689
|
+
cccccc
|
|
1690
|
+
\xB7oooo\xB7`;
|
|
1691
|
+
var merchant = new Sprite(GLYPH2, { defaultKey: "o", colors: COLORS3 });
|
|
1692
|
+
|
|
1693
|
+
// ../client/src/sprites/player.ts
|
|
1694
|
+
var GLYPH3 = `
|
|
1695
|
+
\xB7\u2590\u259B\u2588\u2588\u2588\u259C\u258C\xB7
|
|
1696
|
+
\u259D\u259C\u2588\u2588\u2588\u2588\u2588\u259B\u2598
|
|
1697
|
+
\xB7\xB7\u2598\u2598\xB7\u259D\u259D\xB7\xB7`;
|
|
1698
|
+
var COLORS4 = `
|
|
1699
|
+
\xB7ppppppp\xB7
|
|
1700
|
+
ppppppppp
|
|
1701
|
+
\xB7\xB7pp\xB7pp\xB7\xB7`;
|
|
1702
|
+
var player2 = new Sprite(GLYPH3, { defaultKey: "p", colors: COLORS4 });
|
|
1703
|
+
|
|
1704
|
+
// ../client/src/sprites/shooter.ts
|
|
1705
|
+
var GLYPH4 = `
|
|
1706
|
+
\xB7\u2597\u2584\u2584\u2584\u2596\xB7
|
|
1707
|
+
\u259F\u2588\u2588\u2588\u2588\u2588\u2599
|
|
1708
|
+
\u2588\u2588\u259F\u2588\u2599\u2588\u2588
|
|
1709
|
+
\u259C\u2588\u2588\u2588\u2588\u2588\u259B
|
|
1710
|
+
\xB7\u259D\u2580\u2580\u2580\u2598\xB7`;
|
|
1711
|
+
var COLORS5 = `
|
|
1712
|
+
\xB7ooooo\xB7
|
|
1713
|
+
oogggoo
|
|
1714
|
+
oggkggo
|
|
1715
|
+
oogggoo
|
|
1716
|
+
\xB7ooooo\xB7`;
|
|
1717
|
+
var shooter = new Sprite(GLYPH4, { defaultKey: "o", colors: COLORS5 });
|
|
1718
|
+
|
|
1719
|
+
// ../client/src/sprites/palette.ts
|
|
1720
|
+
import { RGBA as RGBA2 } from "@opentui/core";
|
|
1721
|
+
var PALETTE = {
|
|
1722
|
+
p: RGBA2.fromInts(255, 150, 40, 255),
|
|
1723
|
+
m: RGBA2.fromInts(220, 90, 90, 255),
|
|
1724
|
+
g: RGBA2.fromInts(170, 240, 95, 255),
|
|
1725
|
+
s: RGBA2.fromInts(186, 196, 210, 255),
|
|
1726
|
+
w: RGBA2.fromInts(150, 96, 52, 255),
|
|
1727
|
+
y: RGBA2.fromInts(242, 210, 92, 255),
|
|
1728
|
+
e: RGBA2.fromInts(236, 190, 150, 255),
|
|
1729
|
+
f: RGBA2.fromInts(110, 200, 110, 255),
|
|
1730
|
+
c: RGBA2.fromInts(132, 222, 230, 255),
|
|
1731
|
+
o: RGBA2.fromInts(232, 230, 216, 255),
|
|
1732
|
+
k: RGBA2.fromInts(64, 66, 82, 255)
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
// ../client/src/sprites/index.ts
|
|
1736
|
+
var REGISTRY = {
|
|
1737
|
+
player: player2,
|
|
1738
|
+
chaser,
|
|
1739
|
+
shooter
|
|
1740
|
+
};
|
|
1741
|
+
function spriteFor(type) {
|
|
1742
|
+
return REGISTRY[type];
|
|
1743
|
+
}
|
|
1744
|
+
var NPC_REGISTRY = {
|
|
1745
|
+
vendor: merchant
|
|
1746
|
+
};
|
|
1747
|
+
function spriteForNpc(kind) {
|
|
1748
|
+
return NPC_REGISTRY[kind];
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// ../client/src/playfield.ts
|
|
1752
|
+
function blitSprite(buf, sprite, sx, sy, facing, sw, sh, hurt) {
|
|
1753
|
+
const glyphs = sprite.rows(facing);
|
|
1754
|
+
const keys = sprite.colorKeys(facing);
|
|
1755
|
+
for (let ry = 0;ry < sprite.h; ry++) {
|
|
1756
|
+
const py = sy + ry;
|
|
1757
|
+
if (py < 0 || py >= sh)
|
|
1758
|
+
continue;
|
|
1759
|
+
const row = glyphs[ry];
|
|
1760
|
+
const krow = keys[ry];
|
|
1761
|
+
for (let rx = 0;rx < sprite.w; rx++) {
|
|
1762
|
+
const ch = row[rx];
|
|
1763
|
+
if (ch === " ")
|
|
1764
|
+
continue;
|
|
1765
|
+
const px = sx + rx;
|
|
1766
|
+
if (px < 0 || px >= sw)
|
|
1767
|
+
continue;
|
|
1768
|
+
const fg = hurt ? COLORS.hurt : PALETTE[krow[rx]] ?? COLORS.hud;
|
|
1769
|
+
buf.setCellWithAlphaBlending(px, py, ch, fg, COLORS.transparent);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
function drawSprite(buf, e, cam, sw, sh) {
|
|
1774
|
+
const sprite = spriteFor(e.type);
|
|
1775
|
+
const sx = Math.round(e.x - Math.floor((sprite.w - BOX.w) / 2) - cam.x);
|
|
1776
|
+
const sy = Math.round(e.y + BOX.h - sprite.h - cam.y);
|
|
1777
|
+
blitSprite(buf, sprite, sx, sy, e.facing, sw, sh, e.hurtT > 0.3);
|
|
1778
|
+
}
|
|
1779
|
+
function drawNameplate(buf, e, cam, sw, sh) {
|
|
1780
|
+
if (!e.name)
|
|
1781
|
+
return;
|
|
1782
|
+
const sprite = spriteFor(e.type);
|
|
1783
|
+
const top = Math.round(e.y + BOX.h - sprite.h - cam.y);
|
|
1784
|
+
const cx = e.x + BOX.w / 2 - cam.x;
|
|
1785
|
+
const x = Math.round(cx - e.name.length / 2);
|
|
1786
|
+
drawText(buf, x, top - 1, e.name, COLORS.dim, sw, sh);
|
|
1787
|
+
}
|
|
1788
|
+
function drawSpeechBubble(buf, e, cam, sw, sh) {
|
|
1789
|
+
if (!e.bubble)
|
|
1790
|
+
return;
|
|
1791
|
+
const sprite = spriteFor(e.type);
|
|
1792
|
+
const top = Math.round(e.y + BOX.h - sprite.h - cam.y);
|
|
1793
|
+
const lines = layoutBubble(e.bubble);
|
|
1794
|
+
const innerW = Math.max(...lines.map((l) => l.length));
|
|
1795
|
+
const boxW = innerW + 2;
|
|
1796
|
+
const boxH = lines.length + 2;
|
|
1797
|
+
const cx = e.x + BOX.w / 2 - cam.x;
|
|
1798
|
+
const tailY = top - 2;
|
|
1799
|
+
const tailX = Math.round(cx);
|
|
1800
|
+
const topY = tailY - boxH;
|
|
1801
|
+
let left = Math.round(cx - boxW / 2);
|
|
1802
|
+
left = Math.max(0, Math.min(left, sw - boxW));
|
|
1803
|
+
for (let ry = 0;ry < boxH; ry++) {
|
|
1804
|
+
const py = topY + ry;
|
|
1805
|
+
if (py < 0 || py >= sh)
|
|
1806
|
+
continue;
|
|
1807
|
+
const lastRow = ry === boxH - 1;
|
|
1808
|
+
for (let rx = 0;rx < boxW; rx++) {
|
|
1809
|
+
const px = left + rx;
|
|
1810
|
+
if (px < 0 || px >= sw)
|
|
1811
|
+
continue;
|
|
1812
|
+
const lastCol = rx === boxW - 1;
|
|
1813
|
+
let ch = " ";
|
|
1814
|
+
let fg = COLORS.bubbleFg;
|
|
1815
|
+
if (ry === 0 || lastRow || rx === 0 || lastCol) {
|
|
1816
|
+
fg = COLORS.bubbleBorder;
|
|
1817
|
+
if (ry === 0)
|
|
1818
|
+
ch = rx === 0 ? "\u256D" : lastCol ? "\u256E" : "\u2500";
|
|
1819
|
+
else if (lastRow)
|
|
1820
|
+
ch = rx === 0 ? "\u2570" : lastCol ? "\u256F" : "\u2500";
|
|
1821
|
+
else
|
|
1822
|
+
ch = "\u2502";
|
|
1823
|
+
} else {
|
|
1824
|
+
ch = lines[ry - 1]?.[rx - 1] ?? " ";
|
|
1825
|
+
}
|
|
1826
|
+
buf.setCell(px, py, ch, fg, COLORS.bubbleBg);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (tailY >= 0 && tailY < sh && tailX >= 0 && tailX < sw)
|
|
1830
|
+
buf.setCell(tailX, tailY, "\u25BC", COLORS.bubbleBorder, COLORS.bubbleBg);
|
|
1831
|
+
}
|
|
1832
|
+
function drawText(buf, x, y, text, fg, sw, sh) {
|
|
1833
|
+
if (y < 0 || y >= sh)
|
|
1834
|
+
return;
|
|
1835
|
+
for (let i = 0;i < text.length; i++) {
|
|
1836
|
+
const px = x + i;
|
|
1837
|
+
if (px < 0 || px >= sw)
|
|
1838
|
+
continue;
|
|
1839
|
+
buf.setCellWithAlphaBlending(px, y, text[i], fg, COLORS.transparent);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
function drawPlayfield(buf, game, cam) {
|
|
1843
|
+
const { player: player3 } = game;
|
|
1844
|
+
const zone2 = activeZone(game.world, player3.zoneId);
|
|
1845
|
+
const sw = buf.width;
|
|
1846
|
+
const sh = buf.height;
|
|
1847
|
+
const p = player3.avatar;
|
|
1848
|
+
const ww = zone2.terrain.w;
|
|
1849
|
+
const wh = zone2.terrain.h;
|
|
1850
|
+
const camX = Math.round(cam.x);
|
|
1851
|
+
const camY = Math.round(cam.y);
|
|
1852
|
+
buf.clear(COLORS.bg);
|
|
1853
|
+
for (let sy = 0;sy < sh; sy++) {
|
|
1854
|
+
const wy = sy + camY;
|
|
1855
|
+
for (let sx = 0;sx < sw; sx++) {
|
|
1856
|
+
const wx = sx + camX;
|
|
1857
|
+
if (isSolid(zone2.terrain, wx, wy) && wx >= 0 && wx < ww && wy >= 0 && wy < wh)
|
|
1858
|
+
buf.setCell(sx, sy, "\u2588", COLORS.terrainFg, COLORS.terrainBg);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
const onPortal = zone2.portals.find((pr) => aabbOverlap(entityBox(p), pr));
|
|
1862
|
+
for (const pr of zone2.portals) {
|
|
1863
|
+
for (let yy = 0;yy < pr.h; yy++) {
|
|
1864
|
+
for (let xx = 0;xx < pr.w; xx++) {
|
|
1865
|
+
const px = pr.x + xx - camX;
|
|
1866
|
+
const py = pr.y + yy - camY;
|
|
1867
|
+
if (px >= 0 && px < sw && py >= 0 && py < sh)
|
|
1868
|
+
buf.setCellWithAlphaBlending(px, py, "\u2592", COLORS.portal, COLORS.transparent);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
if (onPortal) {
|
|
1873
|
+
const dest = game.world.zones[onPortal.target]?.type ?? "zone";
|
|
1874
|
+
const label = `\u21B5 e enter the ${dest.charAt(0).toUpperCase()}${dest.slice(1)}`;
|
|
1875
|
+
drawText(buf, Math.round(onPortal.x) - camX, Math.round(onPortal.y) - camY - 1, label, COLORS.portal, sw, sh);
|
|
1876
|
+
}
|
|
1877
|
+
const npcs = zone2.npcs ?? [];
|
|
1878
|
+
const onNpc = npcs.find((n) => aabbOverlap(entityBox(p), n));
|
|
1879
|
+
for (const n of npcs) {
|
|
1880
|
+
const sprite = spriteForNpc(n.kind);
|
|
1881
|
+
const sx = Math.round(n.x + Math.floor((n.w - sprite.w) / 2)) - camX;
|
|
1882
|
+
const sy = Math.round(n.y + n.h - sprite.h) - camY;
|
|
1883
|
+
blitSprite(buf, sprite, sx, sy, 1, sw, sh, false);
|
|
1884
|
+
if (n === onNpc)
|
|
1885
|
+
drawText(buf, sx, sy - 1, `\u21B5 e talk to ${n.name}`, COLORS.vendor, sw, sh);
|
|
1886
|
+
}
|
|
1887
|
+
const others = game.others ?? [];
|
|
1888
|
+
const sprites = [...zone2.monsters, ...others].sort((a, b) => a.y - b.y);
|
|
1889
|
+
for (const e of sprites) {
|
|
1890
|
+
drawSprite(buf, e, cam, sw, sh);
|
|
1891
|
+
drawNameplate(buf, e, cam, sw, sh);
|
|
1892
|
+
}
|
|
1893
|
+
if (p.attackT > COMBAT.attackCooldown - 0.12) {
|
|
1894
|
+
const hb = meleeHitbox(p);
|
|
1895
|
+
for (let yy = 0;yy < hb.h; yy++) {
|
|
1896
|
+
for (let xx = 0;xx < hb.w; xx++) {
|
|
1897
|
+
const px = Math.round(hb.x + xx - cam.x);
|
|
1898
|
+
const py = Math.round(hb.y + yy - cam.y);
|
|
1899
|
+
if (px >= 0 && px < sw && py >= 0 && py < sh)
|
|
1900
|
+
buf.setCellWithAlphaBlending(px, py, p.facing === 1 ? "/" : "\\", COLORS.melee, COLORS.transparent);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
for (let slot = 1;; slot++) {
|
|
1905
|
+
const skill = skillForSlot(player3.class ?? "warrior", slot);
|
|
1906
|
+
if (!skill)
|
|
1907
|
+
break;
|
|
1908
|
+
const cd = player3.skillCooldowns?.[skill.id] ?? 0;
|
|
1909
|
+
if (cd <= skill.cooldown - 0.15)
|
|
1910
|
+
continue;
|
|
1911
|
+
const hb = skillHitbox(p, skill);
|
|
1912
|
+
for (let yy = 0;yy < hb.h; yy++) {
|
|
1913
|
+
for (let xx = 0;xx < hb.w; xx++) {
|
|
1914
|
+
const px = Math.round(hb.x + xx - cam.x);
|
|
1915
|
+
const py = Math.round(hb.y + yy - cam.y);
|
|
1916
|
+
if (px >= 0 && px < sw && py >= 0 && py < sh)
|
|
1917
|
+
buf.setCellWithAlphaBlending(px, py, "\u2726", COLORS.melee, COLORS.transparent);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
drawSprite(buf, p, cam, sw, sh);
|
|
1922
|
+
for (const e of others)
|
|
1923
|
+
drawSpeechBubble(buf, e, cam, sw, sh);
|
|
1924
|
+
drawSpeechBubble(buf, p, cam, sw, sh);
|
|
1925
|
+
for (const pr of zone2.projectiles) {
|
|
1926
|
+
const px = Math.round(pr.x - cam.x);
|
|
1927
|
+
const py = Math.round(pr.y - cam.y);
|
|
1928
|
+
if (px < 0 || px >= sw || py < 0 || py >= sh)
|
|
1929
|
+
continue;
|
|
1930
|
+
const ch = pr.vx < 0 ? "\u25C4" : pr.vx > 0 ? "\u25BA" : "\u25CF";
|
|
1931
|
+
buf.setCellWithAlphaBlending(px, py, ch, COLORS.projectile, COLORS.transparent);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
class PlayfieldRenderable extends Renderable {
|
|
1936
|
+
game = null;
|
|
1937
|
+
camState = initCameraState();
|
|
1938
|
+
constructor(ctx, options = {}) {
|
|
1939
|
+
super(ctx, { width: "100%", height: "100%", live: true, ...options });
|
|
1940
|
+
}
|
|
1941
|
+
renderSelf(buffer) {
|
|
1942
|
+
if (!this.game)
|
|
1943
|
+
return;
|
|
1944
|
+
const zone2 = activeZone(this.game.world, this.game.player.zoneId);
|
|
1945
|
+
const a = this.game.player.avatar;
|
|
1946
|
+
this.camState = stepCamera(this.camState, this.game.player.zoneId, a.x, a.y, {
|
|
1947
|
+
sw: buffer.width,
|
|
1948
|
+
sh: buffer.height,
|
|
1949
|
+
ww: zone2.terrain.w,
|
|
1950
|
+
wh: zone2.terrain.h
|
|
1951
|
+
});
|
|
1952
|
+
if (this.camState.cam)
|
|
1953
|
+
drawPlayfield(buffer, this.game, this.camState.cam);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// ../client/src/shop.ts
|
|
1958
|
+
import {
|
|
1959
|
+
BoxRenderable as BoxRenderable2,
|
|
1960
|
+
TextRenderable as TextRenderable2
|
|
1961
|
+
} from "@opentui/core";
|
|
1962
|
+
var RARITY_PAD = 9;
|
|
1963
|
+
|
|
1964
|
+
class Shop {
|
|
1965
|
+
container;
|
|
1966
|
+
gold;
|
|
1967
|
+
list;
|
|
1968
|
+
selected = 0;
|
|
1969
|
+
constructor(ctx) {
|
|
1970
|
+
this.container = new BoxRenderable2(ctx, {
|
|
1971
|
+
position: "absolute",
|
|
1972
|
+
top: 0,
|
|
1973
|
+
left: 0,
|
|
1974
|
+
right: 0,
|
|
1975
|
+
bottom: 0,
|
|
1976
|
+
justifyContent: "center",
|
|
1977
|
+
alignItems: "center",
|
|
1978
|
+
zIndex: 20,
|
|
1979
|
+
visible: false
|
|
1980
|
+
});
|
|
1981
|
+
const panel = new BoxRenderable2(ctx, {
|
|
1982
|
+
flexDirection: "column",
|
|
1983
|
+
width: 46,
|
|
1984
|
+
padding: 1,
|
|
1985
|
+
border: true,
|
|
1986
|
+
borderStyle: "single",
|
|
1987
|
+
borderColor: COLORS.vendor,
|
|
1988
|
+
title: " Merchant \u2014 sell loot ",
|
|
1989
|
+
titleColor: COLORS.vendor,
|
|
1990
|
+
backgroundColor: COLORS.hudBg
|
|
1991
|
+
});
|
|
1992
|
+
this.gold = new TextRenderable2(ctx, {
|
|
1993
|
+
content: "",
|
|
1994
|
+
fg: COLORS.vendor,
|
|
1995
|
+
bg: COLORS.hudBg
|
|
1996
|
+
});
|
|
1997
|
+
this.list = new TextRenderable2(ctx, {
|
|
1998
|
+
content: "",
|
|
1999
|
+
fg: COLORS.hud,
|
|
2000
|
+
bg: COLORS.hudBg
|
|
2001
|
+
});
|
|
2002
|
+
const footer = new TextRenderable2(ctx, {
|
|
2003
|
+
content: "\u2191/\u2193 select \u21B5 sell e/esc close",
|
|
2004
|
+
fg: COLORS.dim,
|
|
2005
|
+
bg: COLORS.hudBg
|
|
2006
|
+
});
|
|
2007
|
+
panel.add(this.gold);
|
|
2008
|
+
panel.add(this.list);
|
|
2009
|
+
panel.add(footer);
|
|
2010
|
+
this.container.add(panel);
|
|
2011
|
+
}
|
|
2012
|
+
attach(parent) {
|
|
2013
|
+
parent.add(this.container);
|
|
2014
|
+
}
|
|
2015
|
+
get open() {
|
|
2016
|
+
return this.container.visible;
|
|
2017
|
+
}
|
|
2018
|
+
show() {
|
|
2019
|
+
this.selected = 0;
|
|
2020
|
+
this.container.visible = true;
|
|
2021
|
+
}
|
|
2022
|
+
hide() {
|
|
2023
|
+
this.container.visible = false;
|
|
2024
|
+
}
|
|
2025
|
+
move(delta, count) {
|
|
2026
|
+
if (count <= 0) {
|
|
2027
|
+
this.selected = 0;
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
this.selected = Math.max(0, Math.min(count - 1, this.selected + delta));
|
|
2031
|
+
}
|
|
2032
|
+
update(player3) {
|
|
2033
|
+
const inv = player3.inventory;
|
|
2034
|
+
if (this.selected > inv.length - 1)
|
|
2035
|
+
this.selected = Math.max(0, inv.length - 1);
|
|
2036
|
+
this.gold.content = `Gold ${player3.progress.gold}`;
|
|
2037
|
+
if (inv.length === 0) {
|
|
2038
|
+
this.list.content = `
|
|
2039
|
+
(your bags are empty)
|
|
2040
|
+
`;
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
const rows = inv.map((it, i) => {
|
|
2044
|
+
const caret = i === this.selected ? "\u25B8" : " ";
|
|
2045
|
+
const rarity = it.rarity.padEnd(RARITY_PAD);
|
|
2046
|
+
return `${caret} ${rarity} ${it.base} +${saleValue(it)}g`;
|
|
2047
|
+
});
|
|
2048
|
+
this.list.content = `
|
|
2049
|
+
${rows.join(`
|
|
2050
|
+
`)}
|
|
2051
|
+
`;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// ../client/src/index.ts
|
|
2056
|
+
var RENDER_FPS = Number(process.env.MMO_FPS) || 120;
|
|
2057
|
+
var PROD_SERVER = "wss://mmoserver-production-c9d8.up.railway.app";
|
|
2058
|
+
var OFFLINE = process.env.MMO_OFFLINE === "1" || process.env.MMO_OFFLINE === "true";
|
|
2059
|
+
var SERVER = process.env.MMO_SERVER || PROD_SERVER;
|
|
2060
|
+
var IDLE_INPUT = {
|
|
2061
|
+
moveX: 0,
|
|
2062
|
+
jump: false,
|
|
2063
|
+
attack: false,
|
|
2064
|
+
interact: false
|
|
2065
|
+
};
|
|
2066
|
+
var renderer = await createCliRenderer({
|
|
2067
|
+
targetFps: RENDER_FPS,
|
|
2068
|
+
exitOnCtrlC: true,
|
|
2069
|
+
backgroundColor: "#10121a",
|
|
2070
|
+
useKittyKeyboard: { events: true }
|
|
2071
|
+
});
|
|
2072
|
+
var input = new InputState;
|
|
2073
|
+
var playfield = new PlayfieldRenderable(renderer);
|
|
2074
|
+
renderer.root.add(playfield);
|
|
2075
|
+
var hud = new Hud(renderer);
|
|
2076
|
+
hud.attach(renderer.root);
|
|
2077
|
+
function quit(message) {
|
|
2078
|
+
try {
|
|
2079
|
+
renderer.destroy?.();
|
|
2080
|
+
} catch {}
|
|
2081
|
+
if (message)
|
|
2082
|
+
console.error(message);
|
|
2083
|
+
process.exit(message ? 1 : 0);
|
|
2084
|
+
}
|
|
2085
|
+
function fpsMeter() {
|
|
2086
|
+
let fps = 0;
|
|
2087
|
+
let acc = 0;
|
|
2088
|
+
let frames = 0;
|
|
2089
|
+
return (dt) => {
|
|
2090
|
+
acc += dt;
|
|
2091
|
+
frames++;
|
|
2092
|
+
if (acc >= 500) {
|
|
2093
|
+
fps = Math.round(frames * 1000 / acc);
|
|
2094
|
+
acc = 0;
|
|
2095
|
+
frames = 0;
|
|
2096
|
+
}
|
|
2097
|
+
return fps;
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
if (OFFLINE)
|
|
2101
|
+
runOffline();
|
|
2102
|
+
else
|
|
2103
|
+
runNetworked(SERVER);
|
|
2104
|
+
renderer.start();
|
|
2105
|
+
function runOffline() {
|
|
2106
|
+
let game = createGame();
|
|
2107
|
+
const shop = new Shop(renderer);
|
|
2108
|
+
shop.attach(renderer.root);
|
|
2109
|
+
function vendorUnder(g) {
|
|
2110
|
+
const zone2 = activeZone(g.world, g.player.zoneId);
|
|
2111
|
+
const box = entityBox(g.player.avatar);
|
|
2112
|
+
return (zone2.npcs ?? []).find((n) => n.kind === "vendor" && aabbOverlap(box, n)) ?? null;
|
|
2113
|
+
}
|
|
2114
|
+
function sellSelected() {
|
|
2115
|
+
const inv = game.player.inventory;
|
|
2116
|
+
if (inv.length === 0)
|
|
2117
|
+
return;
|
|
2118
|
+
const item = inv[shop.selected];
|
|
2119
|
+
const { progress, inventory } = sellItem(game.player.progress, inv, item.id);
|
|
2120
|
+
const log = [
|
|
2121
|
+
...game.player.log.slice(-5),
|
|
2122
|
+
`Sold ${item.rarity} ${item.base} (+${saleValue(item)}g).`
|
|
2123
|
+
];
|
|
2124
|
+
game = { ...game, player: { ...game.player, progress, inventory, log } };
|
|
2125
|
+
shop.move(0, inventory.length);
|
|
2126
|
+
}
|
|
2127
|
+
function handleShopKey(name) {
|
|
2128
|
+
const count = game.player.inventory.length;
|
|
2129
|
+
switch (name) {
|
|
2130
|
+
case "up":
|
|
2131
|
+
shop.move(-1, count);
|
|
2132
|
+
break;
|
|
2133
|
+
case "down":
|
|
2134
|
+
shop.move(1, count);
|
|
2135
|
+
break;
|
|
2136
|
+
case "return":
|
|
2137
|
+
sellSelected();
|
|
2138
|
+
break;
|
|
2139
|
+
case "e":
|
|
2140
|
+
case "escape":
|
|
2141
|
+
shop.hide();
|
|
2142
|
+
break;
|
|
2143
|
+
}
|
|
2144
|
+
if (shop.open)
|
|
2145
|
+
shop.update(game.player);
|
|
2146
|
+
}
|
|
2147
|
+
renderer.keyInput.on("keypress", (k) => {
|
|
2148
|
+
if (k.name === "q")
|
|
2149
|
+
quit();
|
|
2150
|
+
if (shop.open) {
|
|
2151
|
+
handleShopKey(k.name);
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
if (k.name === "e" && vendorUnder(game)) {
|
|
2155
|
+
shop.show();
|
|
2156
|
+
shop.update(game.player);
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
input.press(k.name, performance.now());
|
|
2160
|
+
});
|
|
2161
|
+
renderer.keyInput.on("keyrelease", (k) => input.release(k.name));
|
|
2162
|
+
const meter = fpsMeter();
|
|
2163
|
+
renderer.setFrameCallback(async (dt) => {
|
|
2164
|
+
game = step(game, shop.open ? IDLE_INPUT : input.poll(performance.now()), dt);
|
|
2165
|
+
const fps = meter(dt);
|
|
2166
|
+
playfield.game = game;
|
|
2167
|
+
hud.update(game, fps);
|
|
2168
|
+
if (shop.open)
|
|
2169
|
+
shop.update(game.player);
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
function localZone(id) {
|
|
2173
|
+
return id === "town-01" ? makeTownZone(id) : makeFieldZone(id || "field-01");
|
|
2174
|
+
}
|
|
2175
|
+
function runNetworked(url) {
|
|
2176
|
+
const handle = process.env.USER || "wanderer";
|
|
2177
|
+
const net = new NetClient(url, handle, (reason) => {
|
|
2178
|
+
quit(reason);
|
|
2179
|
+
});
|
|
2180
|
+
hud.showAlphaNotice();
|
|
2181
|
+
let zoneId = "field-01";
|
|
2182
|
+
let zone2 = localZone(zoneId);
|
|
2183
|
+
let predicted = spawnAvatar(SPAWN.x, SPAWN.y);
|
|
2184
|
+
const localCd = {};
|
|
2185
|
+
const RECONCILE = 3;
|
|
2186
|
+
const SEND_INTERVAL = 1000 / 30;
|
|
2187
|
+
let sendAcc = 0;
|
|
2188
|
+
const chat = new ChatInput;
|
|
2189
|
+
renderer.keyInput.on("keypress", (k) => {
|
|
2190
|
+
if (chat.open) {
|
|
2191
|
+
const r = chat.key(k);
|
|
2192
|
+
if (r.action === "send")
|
|
2193
|
+
net.send({ t: "chat", text: r.text });
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
if (k.name === "q")
|
|
2197
|
+
quit();
|
|
2198
|
+
if (k.name === "return") {
|
|
2199
|
+
chat.start();
|
|
2200
|
+
input.clear();
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
input.press(k.name, performance.now());
|
|
2204
|
+
});
|
|
2205
|
+
renderer.keyInput.on("keyrelease", (k) => {
|
|
2206
|
+
if (!chat.open)
|
|
2207
|
+
input.release(k.name);
|
|
2208
|
+
});
|
|
2209
|
+
const meter = fpsMeter();
|
|
2210
|
+
renderer.setFrameCallback(async (dt) => {
|
|
2211
|
+
const inp = chat.open ? IDLE_INPUT : input.poll(performance.now());
|
|
2212
|
+
if (net.zoneId && net.zoneId !== zoneId) {
|
|
2213
|
+
zoneId = net.zoneId;
|
|
2214
|
+
zone2 = localZone(zoneId);
|
|
2215
|
+
const arrival = net.ownAvatar();
|
|
2216
|
+
if (arrival)
|
|
2217
|
+
predicted = {
|
|
2218
|
+
...predicted,
|
|
2219
|
+
x: arrival.x,
|
|
2220
|
+
y: arrival.y,
|
|
2221
|
+
vx: 0,
|
|
2222
|
+
vy: 0,
|
|
2223
|
+
onGround: false
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
predicted = clientStepAvatar(zone2.terrain, predicted, { moveX: inp.moveX, jump: inp.jump }, dt);
|
|
2227
|
+
if (inp.attack && predicted.attackT <= 0)
|
|
2228
|
+
predicted.attackT = COMBAT.attackCooldown;
|
|
2229
|
+
const dtSec = Math.min(dt / 1000, PHYS.maxDt);
|
|
2230
|
+
for (const id in localCd)
|
|
2231
|
+
localCd[id] = Math.max(0, localCd[id] - dtSec);
|
|
2232
|
+
const level = net.latest?.progress.level ?? 1;
|
|
2233
|
+
if (inp.skill) {
|
|
2234
|
+
const skill = skillForSlot("warrior", inp.skill);
|
|
2235
|
+
if (skill && skillUnlocked(skill, level) && (localCd[skill.id] ?? 0) <= 0)
|
|
2236
|
+
localCd[skill.id] = skill.cooldown;
|
|
2237
|
+
}
|
|
2238
|
+
const own = net.ownAvatar();
|
|
2239
|
+
if (own) {
|
|
2240
|
+
predicted.hp = own.hp;
|
|
2241
|
+
predicted.maxHp = own.maxHp;
|
|
2242
|
+
predicted.hurtT = own.hurtT;
|
|
2243
|
+
if (Math.abs(own.x - predicted.x) > RECONCILE || Math.abs(own.y - predicted.y) > RECONCILE) {
|
|
2244
|
+
predicted.x = own.x;
|
|
2245
|
+
predicted.y = own.y;
|
|
2246
|
+
predicted.vx = 0;
|
|
2247
|
+
predicted.vy = 0;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
sendAcc += dt;
|
|
2251
|
+
if (sendAcc >= SEND_INTERVAL) {
|
|
2252
|
+
sendAcc = 0;
|
|
2253
|
+
net.send({
|
|
2254
|
+
t: "input",
|
|
2255
|
+
x: predicted.x,
|
|
2256
|
+
y: predicted.y,
|
|
2257
|
+
vx: predicted.vx,
|
|
2258
|
+
vy: predicted.vy,
|
|
2259
|
+
facing: predicted.facing,
|
|
2260
|
+
onGround: predicted.onGround,
|
|
2261
|
+
attack: inp.attack,
|
|
2262
|
+
interact: inp.interact ?? false,
|
|
2263
|
+
skill: inp.skill
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
const fps = meter(dt);
|
|
2267
|
+
const view = net.sample(performance.now());
|
|
2268
|
+
net.decayBubbles(dt / 1000);
|
|
2269
|
+
const game = snapshotToGame(zone2, predicted, net.sessionId, view, localCd, net.bubbles);
|
|
2270
|
+
playfield.game = game;
|
|
2271
|
+
hud.update(game, fps);
|
|
2272
|
+
hud.updateChat(net.chatLog, chat.open, chat.text);
|
|
2273
|
+
});
|
|
2274
|
+
}
|