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.
Files changed (2) hide show
  1. package/dist/cli.js +2274 -0
  2. 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
+ }