Public: anyone can read this; 1 person and the crew can change it.
From outside: Community vote winner: character classes (archery, mage, etc.) #6
Check · Review · Land · Live — It conflicts with its base.
cheffry-james/cheffry-s-dungeon:cheffry-james/3-from-outside-community-vote-winner-chara → main 2 commits fork
19 files
changed +966
−65
README.md +30 −3
| @@ -31,6 +31,21 @@ it: the preview comes up the moment the app answers, and says `live`, or | ||
| 31 | 31 | the nearest), right-click / `Shift`+click / `K` / `Shift` to dodge through |
| 32 | 32 | a hit. WASD or arrows steer. A downed chef gets back up if a friend stands |
| 33 | 33 | over them. Clear five rooms of the day's dungeon to win. |
| 34 | - **Three classes** — the community's first vote, called by Ben Versluys. | |
| 35 | Change in **Cheffry's dressing room** on the hub page (the curtain on the | |
| 36 | square's left wall) between delves; you keep what you wear until you | |
| 37 | change, and your next table seats you as it. | |
| 38 | - **Knight** (tank) — cleaver and pot lid, the most hearts, a steady | |
| 39 | shoulder roll. *Order Up!* pulls every monster nearby onto the Knight, | |
| 40 | and the lid takes a point off each hit while it lasts. | |
| 41 | - **Archer** (damage) — fires skewers from six tiles off, a light apron, | |
| 42 | a dash that is ready again almost at once. *Skewer Storm* looses five | |
| 43 | in a fan. | |
| 44 | - **Mage** (support) — slow fireballs that splash, three hearts, a blink | |
| 45 | instead of a roll. *Soup's On!* heals every chef close by and gets the | |
| 46 | downed back up. | |
| 47 | ||
| 48 | `Q` (or the third button) is your class's signature move. | |
| 34 | 49 | - **Three enemies, three brains** — the Meatball Slime (chases), the |
| 35 | 50 | Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance |
| 36 | 51 | and shoots) — plus Skewer Spikes and chests of coins, truffles, soup and |
| @@ -44,17 +59,29 @@ it: the preview comes up the moment the app answers, and says `live`, or | ||
| 44 | 59 | ## Content is data |
| 45 | 60 | |
| 46 | 61 | `content/*.json` is the game: `rooms.json` (layouts, spawns, traps, chests), |
| 47 | `monsters.json`, `traps.json`, `loot.json`, `rules.json` (every tuning number, | |
| 48 | in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole | |
| 62 | `monsters.json`, `traps.json`, `loot.json`, `classes.json`, `rules.json` | |
| 63 | (every tuning number, in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole | |
| 49 | 64 | at boot and refuses to start on a bad one, naming the field. A new room or a |
| 50 | 65 | new monster built from an existing brain is a JSON edit and nothing else. |
| 51 | 66 | |
| 67 | A class is the same: an entry in `classes.json` lays its own numbers over | |
| 68 | `rules.hero` (hearts, speed, reach, the feel of its dodge), picks an attack | |
| 69 | (`melee` or `shot`, with `splash` if it should), a signature move (`taunt`, | |
| 70 | `volley` or `heal`, tuned by the numbers beside it), a weapon to draw | |
| 71 | (`sword`, `bow`, `staff`) and an apron colour. The next voted class built | |
| 72 | from those is a JSON edit; a new *kind* of move is code (`cast` in | |
| 73 | `src/sim.ts`). The first class is the default. | |
| 74 | ||
| 75 | The choice a person wears lives in memory beside the hub (it outlasts | |
| 76 | wandering off, not a restart), and `POST /api/delves` / `POST /api/heroes` | |
| 77 | take an optional `class` to sit down as something else. | |
| 78 | ||
| 52 | 79 | ## The networking |
| 53 | 80 | |
| 54 | 81 | A delve is `src/sim.ts`: a pure, seeded, 20 Hz tick with no imports, so the |
| 55 | 82 | same seed and the same orders make the same run anywhere. The server owns |
| 56 | 83 | every instance (`src/delves.ts`) and is the authority; clients send orders |
| 57 | (`move`, `attack`, `dodge`, `stop`) and receive snapshots over | |
| 84 | (`move`, `attack`, `dodge`, `ability`, `stop`) and receive snapshots over | |
| 58 | 85 | `/delves/<id>/events`. The page loads the very same sim as `/sim.js` to lay |
| 59 | 86 | out rooms. Instances share nothing but the database, so they would split |
| 60 | 87 | across processes by delve id when one process is no longer enough — v1 |
content/classes.json added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | { | |
| 2 | "$description": "Who a chef is in a delve: the community's picks, voted in. The first class is the default. `hero` overrides any number in rules.hero for this class (hp, speed, damage, reach, swing, cooldown, dodgeSpeed, dodgeTicks, dodgeCooldown). `attack.kind` is melee (a swing) or shot (a projectile; `splash` hits everything that close to where it lands). `ability.kind` is which signature move the class gets (taunt, volley or heal) and the numbers beside it tune it. `dodge` says how its dodge feels (its numbers are in `hero`); `look` is the weapon the renderer draws (sword, bow or staff); `paint` is the apron's colour token. A new class built from an existing attack, ability and look is a JSON edit and nothing else.", | |
| 3 | "classes": [ | |
| 4 | { | |
| 5 | "id": "knight", | |
| 6 | "name": "Knight", | |
| 7 | "weapon": "Cleaver & Pot Lid", | |
| 8 | "role": "tank", | |
| 9 | "blurb": "Sturdy, straightforward, smells faintly of garlic. Stands in front so nobody else has to.", | |
| 10 | "look": "sword", | |
| 11 | "dodge": "Shoulder roll: short, steady, hard to stop", | |
| 12 | "paint": "game-apron", | |
| 13 | "hero": {}, | |
| 14 | "attack": { "kind": "melee" }, | |
| 15 | "ability": { | |
| 16 | "kind": "taunt", | |
| 17 | "name": "Order Up!", | |
| 18 | "says": "Bellows so loud every monster nearby comes for the Knight, whose pot lid shrugs off a point of every hit for a while.", | |
| 19 | "cooldown": 300, | |
| 20 | "radius": 5, | |
| 21 | "ticks": 80, | |
| 22 | "block": 1 | |
| 23 | } | |
| 24 | }, | |
| 25 | { | |
| 26 | "id": "archer", | |
| 27 | "name": "Archer", | |
| 28 | "weapon": "Skewer Bow", | |
| 29 | "role": "damage", | |
| 30 | "blurb": "Fires kebab skewers from a safe distance. Light apron, quick feet, zero chill.", | |
| 31 | "look": "bow", | |
| 32 | "dodge": "Quick dash: snappy, and ready again almost at once", | |
| 33 | "paint": "game-leaf", | |
| 34 | "hero": { | |
| 35 | "hp": 4, | |
| 36 | "speed": 0.18, | |
| 37 | "reach": 6, | |
| 38 | "swing": 5, | |
| 39 | "cooldown": 11, | |
| 40 | "dodgeSpeed": 0.5, | |
| 41 | "dodgeTicks": 5, | |
| 42 | "dodgeCooldown": 12 | |
| 43 | }, | |
| 44 | "attack": { "kind": "shot", "shotSpeed": 0.45 }, | |
| 45 | "ability": { | |
| 46 | "kind": "volley", | |
| 47 | "name": "Skewer Storm", | |
| 48 | "says": "Five skewers in a fan. Somebody's getting kebabbed.", | |
| 49 | "cooldown": 200, | |
| 50 | "count": 5, | |
| 51 | "spread": 0.22 | |
| 52 | } | |
| 53 | }, | |
| 54 | { | |
| 55 | "id": "mage", | |
| 56 | "name": "Mage", | |
| 57 | "weapon": "Ladle of Flambé", | |
| 58 | "role": "support", | |
| 59 | "blurb": "Throws fireballs that splash. Made of tissue paper. Blinks instead of rolling. Brings soup.", | |
| 60 | "look": "staff", | |
| 61 | "dodge": "Blink: there, then suddenly over there — slow to recharge", | |
| 62 | "paint": "game-water", | |
| 63 | "hero": { | |
| 64 | "hp": 3, | |
| 65 | "speed": 0.15, | |
| 66 | "damage": 3, | |
| 67 | "reach": 5, | |
| 68 | "swing": 8, | |
| 69 | "cooldown": 30, | |
| 70 | "dodgeSpeed": 1.1, | |
| 71 | "dodgeTicks": 2, | |
| 72 | "dodgeCooldown": 50 | |
| 73 | }, | |
| 74 | "attack": { "kind": "shot", "shotSpeed": 0.22, "splash": 1 }, | |
| 75 | "ability": { | |
| 76 | "kind": "heal", | |
| 77 | "name": "Soup's On!", | |
| 78 | "says": "A pot of healing broth: every chef close by gets hearts back, and anyone downed gets up.", | |
| 79 | "cooldown": 400, | |
| 80 | "radius": 3.5, | |
| 81 | "heal": 3 | |
| 82 | } | |
| 83 | } | |
| 84 | ] | |
| 85 | } |
content/hub.json +2
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" }, |
| 8 | 8 | { "id": "barrel-1", "shape": "prism", "x": 3, "z": 13, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" }, |
| 9 | 9 | { "id": "barrel-2", "shape": "prism", "x": 4.2, "z": 13.5, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" }, |
| 10 | { "id": "dressing-room", "shape": "box", "x": 1.2, "z": 8, "w": 1.4, "h": 2.2, "d": 2.6, "paint": "game-pepper" }, | |
| 11 | { "id": "mirror", "shape": "box", "x": 2.1, "z": 6.4, "w": 0.2, "h": 1.6, "d": 0.9, "paint": "game-water" }, | |
| 10 | 12 | { "id": "table", "shape": "box", "x": 17, "z": 12, "w": 3, "h": 0.7, "d": 1.6, "paint": "game-wall" } |
| 11 | 13 | ] |
| 12 | 14 | } |
contract/openapi.json +64 −5
| @@ -368,7 +368,7 @@ | ||
| 368 | 368 | } |
| 369 | 369 | }, |
| 370 | 370 | "patch": { |
| 371 | "summary": "Walk somewhere, emote, or say something.", | |
| 371 | "summary": "Walk somewhere, emote, say something, or change class in the dressing room.", | |
| 372 | 372 | "operationId": "patchApiAvatarsId", |
| 373 | 373 | "parameters": [ |
| 374 | 374 | { |
| @@ -386,7 +386,7 @@ | ||
| 386 | 386 | "application/json": { |
| 387 | 387 | "schema": { |
| 388 | 388 | "type": "object", |
| 389 | "description": "Any of: where to walk (x and z together), an emote, words.", | |
| 389 | "description": "Any of: where to walk (x and z together), an emote, words, a class to wear into the next delve.", | |
| 390 | 390 | "properties": { |
| 391 | 391 | "x": { |
| 392 | 392 | "type": "number" |
| @@ -407,6 +407,13 @@ | ||
| 407 | 407 | "type": "string", |
| 408 | 408 | "minLength": 1, |
| 409 | 409 | "maxLength": 140 |
| 410 | }, | |
| 411 | "class": { | |
| 412 | "enum": [ | |
| 413 | "knight", | |
| 414 | "archer", | |
| 415 | "mage" | |
| 416 | ] | |
| 410 | 417 | } |
| 411 | 418 | } |
| 412 | 419 | } |
| @@ -415,7 +422,7 @@ | ||
| 415 | 422 | }, |
| 416 | 423 | "responses": { |
| 417 | 424 | "200": { |
| 418 | "description": "Walk somewhere, emote, or say something.", | |
| 425 | "description": "Walk somewhere, emote, say something, or change class in the dressing room.", | |
| 419 | 426 | "content": { |
| 420 | 427 | "application/json": { |
| 421 | 428 | "schema": { |
| @@ -529,6 +536,26 @@ | ||
| 529 | 536 | "post": { |
| 530 | 537 | "summary": "Open today's dungeon with yourself at the table.", |
| 531 | 538 | "operationId": "postApiDelves", |
| 539 | "requestBody": { | |
| 540 | "required": true, | |
| 541 | "content": { | |
| 542 | "application/json": { | |
| 543 | "schema": { | |
| 544 | "type": "object", | |
| 545 | "properties": { | |
| 546 | "class": { | |
| 547 | "enum": [ | |
| 548 | "knight", | |
| 549 | "archer", | |
| 550 | "mage" | |
| 551 | ], | |
| 552 | "description": "Which class to cook as; what you wear in the hub when left out." | |
| 553 | } | |
| 554 | } | |
| 555 | } | |
| 556 | } | |
| 557 | } | |
| 558 | }, | |
| 532 | 559 | "responses": { |
| 533 | 560 | "201": { |
| 534 | 561 | "description": "Open today's dungeon with yourself at the table.", |
| @@ -648,6 +675,14 @@ | ||
| 648 | 675 | "properties": { |
| 649 | 676 | "delve": { |
| 650 | 677 | "type": "integer" |
| 678 | }, | |
| 679 | "class": { | |
| 680 | "enum": [ | |
| 681 | "knight", | |
| 682 | "archer", | |
| 683 | "mage" | |
| 684 | ], | |
| 685 | "description": "Which class to cook as; what you wear in the hub when left out." | |
| 651 | 686 | } |
| 652 | 687 | }, |
| 653 | 688 | "required": [ |
| @@ -747,13 +782,14 @@ | ||
| 747 | 782 | "application/json": { |
| 748 | 783 | "schema": { |
| 749 | 784 | "type": "object", |
| 750 | "description": "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). stop.", | |
| 785 | "description": "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). ability: your class's signature move. stop.", | |
| 751 | 786 | "properties": { |
| 752 | 787 | "type": { |
| 753 | 788 | "enum": [ |
| 754 | 789 | "move", |
| 755 | 790 | "attack", |
| 756 | 791 | "dodge", |
| 792 | "ability", | |
| 757 | 793 | "stop" |
| 758 | 794 | ] |
| 759 | 795 | }, |
| @@ -1253,6 +1289,13 @@ | ||
| 1253 | 1289 | "name": { |
| 1254 | 1290 | "type": "string" |
| 1255 | 1291 | }, |
| 1292 | "class": { | |
| 1293 | "enum": [ | |
| 1294 | "knight", | |
| 1295 | "archer", | |
| 1296 | "mage" | |
| 1297 | ] | |
| 1298 | }, | |
| 1256 | 1299 | "x": { |
| 1257 | 1300 | "type": "number" |
| 1258 | 1301 | }, |
| @@ -1291,6 +1334,7 @@ | ||
| 1291 | 1334 | "id", |
| 1292 | 1335 | "author_id", |
| 1293 | 1336 | "name", |
| 1337 | "class", | |
| 1294 | 1338 | "x", |
| 1295 | 1339 | "z", |
| 1296 | 1340 | "tx", |
| @@ -1328,6 +1372,13 @@ | ||
| 1328 | 1372 | }, |
| 1329 | 1373 | "name": { |
| 1330 | 1374 | "type": "string" |
| 1375 | }, | |
| 1376 | "class": { | |
| 1377 | "enum": [ | |
| 1378 | "knight", | |
| 1379 | "archer", | |
| 1380 | "mage" | |
| 1381 | ] | |
| 1331 | 1382 | } |
| 1332 | 1383 | } |
| 1333 | 1384 | } |
| @@ -1385,13 +1436,21 @@ | ||
| 1385 | 1436 | }, |
| 1386 | 1437 | "author_name": { |
| 1387 | 1438 | "type": "string" |
| 1439 | }, | |
| 1440 | "class": { | |
| 1441 | "enum": [ | |
| 1442 | "knight", | |
| 1443 | "archer", | |
| 1444 | "mage" | |
| 1445 | ] | |
| 1388 | 1446 | } |
| 1389 | 1447 | }, |
| 1390 | 1448 | "required": [ |
| 1391 | 1449 | "id", |
| 1392 | 1450 | "delve_id", |
| 1393 | 1451 | "author_id", |
| 1394 | "author_name" | |
| 1452 | "author_name", | |
| 1453 | "class" | |
| 1395 | 1454 | ] |
| 1396 | 1455 | }, |
| 1397 | 1456 | "Run": { |
src/actions.ts +45 −17
| @@ -376,9 +376,18 @@ export interface AvatarChange { | ||
| 376 | 376 | readonly z?: unknown; |
| 377 | 377 | readonly emote?: unknown; |
| 378 | 378 | readonly say?: unknown; |
| 379 | readonly class?: unknown; | |
| 379 | 380 | } |
| 380 | 381 | |
| 381 | /** Walk somewhere, strike a pose, or say something — any of them at once. */ | |
| 382 | /** A class the content knows, by id; a reason when it is not one. */ | |
| 383 | function readClass(value: unknown): string | Result<never> { | |
| 384 | const found = game.classes.find((c) => c.id === value); | |
| 385 | return found !== undefined | |
| 386 | ? found.id | |
| 387 | : fail("invalid", `A class is one of ${game.classes.map((c) => c.id).join(", ")}.`); | |
| 388 | } | |
| 389 | ||
| 390 | /** Walk somewhere, strike a pose, say something, or change class — any of them at once. */ | |
| 382 | 391 | export const updateAvatar = ( |
| 383 | 392 | db: Database, |
| 384 | 393 | who: Who, |
| @@ -404,12 +413,15 @@ function changeAvatar(who: Who, input: AvatarChange): Result<AvatarView> { | ||
| 404 | 413 | input.say === undefined ? undefined : String(input.say).trim(); |
| 405 | 414 | if (words !== undefined && (words.length === 0 || words.length > SAY_MAX)) |
| 406 | 415 | return fail("invalid", `Words in the hub are 1 to ${SAY_MAX} characters.`); |
| 407 | if (!walking && input.emote === undefined && words === undefined) | |
| 408 | return fail("invalid", "Say where to walk, an emote, or something to say."); | |
| 416 | const cls = input.class === undefined ? undefined : readClass(input.class); | |
| 417 | if (typeof cls === "object") return cls; | |
| 418 | if (!walking && input.emote === undefined && words === undefined && cls === undefined) | |
| 419 | return fail("invalid", "Say where to walk, an emote, something to say, or a class to wear."); | |
| 409 | 420 | const now = Date.now(); |
| 410 | 421 | const avatar = found.value; |
| 411 | 422 | if (walking) hub.walk(avatar, input.x as number, input.z as number, now); |
| 412 | 423 | if (input.emote !== undefined) hub.emote(avatar, String(input.emote), now); |
| 424 | if (cls !== undefined) hub.dress(avatar, cls, now); | |
| 413 | 425 | if (words !== undefined) { |
| 414 | 426 | hub.say(avatar, words, now); |
| 415 | 427 | emit("hub.chat", { id: avatar.id }); |
| @@ -499,21 +511,34 @@ export function watchDelve( | ||
| 499 | 511 | return found.ok ? ok(found.value.bus) : found; |
| 500 | 512 | } |
| 501 | 513 | |
| 502 | /** | |
| 503 | * Open today's dungeon with yourself at the table. Whoever is in another | |
| 504 | * delve leaves it first: a chef cooks in one kitchen at a time. | |
| 505 | */ | |
| 506 | export const openDelve = (db: Database, who: Who): Result<DelveView> => | |
| 507 | audited(db, who, "delve", "create", undefined, openOne(db, who)); | |
| 514 | /** Which class a chef sits down as: the one asked for, else what they wear in the hub. */ | |
| 515 | const classFor = (who: { id: string }, asked: unknown): string | Result<never> => | |
| 516 | asked === undefined || asked === null || asked === "" | |
| 517 | ? hub.outfitOf(who.id) | |
| 518 | : readClass(asked); | |
| 508 | 519 | |
| 509 | function openOne(db: Database, who: Who): Result<DelveView> { | |
| 520 | /** | |
| 521 | * Open today's dungeon with yourself at the table, as the class you wear | |
| 522 | * (or the one asked for). Whoever is in another delve leaves it first: a | |
| 523 | * chef cooks in one kitchen at a time. | |
| 524 | */ | |
| 525 | export const openDelve = ( | |
| 526 | db: Database, | |
| 527 | who: Who, | |
| 528 | input: { class?: unknown } = {}, | |
| 529 | ): Result<DelveView> => | |
| 530 | audited(db, who, "delve", "create", undefined, openOne(db, who, input)); | |
| 531 | ||
| 532 | function openOne(db: Database, who: Who, input: { class?: unknown }): Result<DelveView> { | |
| 510 | 533 | if (who.role === "anon" || permit(who, "delve", "create") !== "yes") |
| 511 | 534 | return refuse(who, "Sign in to start a delve.", "You may not start a delve."); |
| 535 | const cls = classFor(who, input.class); | |
| 536 | if (typeof cls === "object") return cls; | |
| 512 | 537 | const now = Date.now(); |
| 513 | 538 | const before = kitchen.heroOfAuthor(who.id); |
| 514 | 539 | if (before !== undefined) kitchen.dismiss(before, now); |
| 515 | 540 | const delve = kitchen.open(who, now, (d) => recordRun(db, d)); |
| 516 | kitchen.enlist(delve, who, now); | |
| 541 | kitchen.enlist(delve, who, now, cls); | |
| 517 | 542 | emit("delve.opened", { id: delve.id }); |
| 518 | 543 | return ok(kitchen.view(delve)); |
| 519 | 544 | } |
| @@ -555,19 +580,21 @@ export function getHero( | ||
| 555 | 580 | return found.ok ? ok(kitchen.heroView(found.value)) : found; |
| 556 | 581 | } |
| 557 | 582 | |
| 558 | /** Join a party: take one of its four places, as yourself. */ | |
| 583 | /** Join a party: take one of its four places, as yourself, in the class you wear (or the one asked for). */ | |
| 559 | 584 | export const joinDelve = ( |
| 560 | 585 | db: Database, |
| 561 | 586 | who: Who, |
| 562 | input: { delve: number }, | |
| 587 | input: { delve: number; class?: unknown }, | |
| 563 | 588 | ): Result<HeroView> => |
| 564 | 589 | audited(db, who, "hero", "create", undefined, join(who, input)); |
| 565 | 590 | |
| 566 | function join(who: Who, input: { delve: number }): Result<HeroView> { | |
| 591 | function join(who: Who, input: { delve: number; class?: unknown }): Result<HeroView> { | |
| 567 | 592 | if (who.role === "anon" || permit(who, "hero", "create") !== "yes") |
| 568 | 593 | return refuse(who, "Sign in to join a party.", "You may not join a party."); |
| 569 | 594 | if (!Number.isInteger(input.delve)) |
| 570 | 595 | return fail("invalid", "A delve id is a whole number."); |
| 596 | const cls = classFor(who, input.class); | |
| 597 | if (typeof cls === "object") return cls; | |
| 571 | 598 | const delve = kitchen.delveOf(input.delve); |
| 572 | 599 | if (delve === undefined || delve.ended !== null) |
| 573 | 600 | return fail("not_found", "That delve is over, or never was."); |
| @@ -577,7 +604,7 @@ function join(who: Who, input: { delve: number }): Result<HeroView> { | ||
| 577 | 604 | if (!kitchen.joinable(delve)) |
| 578 | 605 | return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`); |
| 579 | 606 | if (before !== undefined) kitchen.dismiss(before, now); |
| 580 | const hero = kitchen.enlist(delve, who, now); | |
| 607 | const hero = kitchen.enlist(delve, who, now, cls); | |
| 581 | 608 | emit("delve.changed", { id: delve.id }); |
| 582 | 609 | return ok(kitchen.heroView(hero)); |
| 583 | 610 | } |
| @@ -609,10 +636,11 @@ export function readOrder( | ||
| 609 | 636 | : "A target is a monster's id, a whole number."; |
| 610 | 637 | } |
| 611 | 638 | if (type === "stop") return { type: "stop" }; |
| 612 | return "An order is move, attack, dodge or stop."; | |
| 639 | if (type === "ability") return { type: "ability" }; | |
| 640 | return "An order is move, attack, dodge, ability or stop."; | |
| 613 | 641 | } |
| 614 | 642 | |
| 615 | /** Give your hero an order: move, attack, dodge or stop. */ | |
| 643 | /** Give your hero an order: move, attack, dodge, ability or stop. */ | |
| 616 | 644 | export const commandHero = ( |
| 617 | 645 | db: Database, |
| 618 | 646 | who: Who, |
src/classes.test.ts added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | /** | |
| 2 | * Classes, in the sim: each lays its numbers over the base rules, attacks | |
| 3 | * its own way, dodges its own way, and has one signature move — and the | |
| 4 | * content checker refuses a class it could not play. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { check, game } from "./content.ts"; | |
| 8 | import { | |
| 9 | addHero, | |
| 10 | classOf, | |
| 11 | type Content, | |
| 12 | createWorld, | |
| 13 | order, | |
| 14 | rulesOf, | |
| 15 | snapshot, | |
| 16 | step, | |
| 17 | type World, | |
| 18 | } from "./sim.ts"; | |
| 19 | ||
| 20 | const kitchen = (map: string[]): Content => ({ | |
| 21 | ...game, | |
| 22 | rules: { ...game.rules, roomsPerRun: 1 }, | |
| 23 | rooms: [{ id: "r0", name: "Room", final: true, map }], | |
| 24 | }); | |
| 25 | const run = (content: Content, world: World, ticks: number) => { | |
| 26 | const seen: string[] = []; | |
| 27 | for (let i = 0; i < ticks && world.over === null; i++) | |
| 28 | for (const h of step(content, world)) seen.push(h.type); | |
| 29 | return seen; | |
| 30 | }; | |
| 31 | const LONG = ["##############", "#S.........m.#", "#...........E#", "##############"]; | |
| 32 | ||
| 33 | test("the vote is in: a Knight, an Archer and a Mage, the Knight the default", () => { | |
| 34 | expect(game.classes.map((c) => c.id)).toEqual(["knight", "archer", "mage"]); | |
| 35 | expect(classOf(game, "nonsense").id).toBe("knight"); | |
| 36 | // The Knight is the chef as it always was. | |
| 37 | expect(rulesOf(game, "knight")).toEqual(game.rules.hero); | |
| 38 | // Tank, damage, support: the Knight has the most hearts, the Mage the fewest. | |
| 39 | const hp = (id: string) => rulesOf(game, id).hp; | |
| 40 | expect(hp("knight")).toBeGreaterThan(hp("archer")); | |
| 41 | expect(hp("archer")).toBeGreaterThan(hp("mage")); | |
| 42 | // The Archer dodges again soonest; the Mage blinks fastest. | |
| 43 | expect(rulesOf(game, "archer").dodgeCooldown).toBeLessThan(rulesOf(game, "knight").dodgeCooldown); | |
| 44 | expect(rulesOf(game, "mage").dodgeSpeed).toBeGreaterThan(rulesOf(game, "archer").dodgeSpeed); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a hero is its class: hearts, and the snapshot says which", () => { | |
| 48 | const c = kitchen(LONG); | |
| 49 | const w = createWorld(c, 1); | |
| 50 | const archer = addHero(c, w, 1, "a", "archer"); | |
| 51 | const unknown = addHero(c, w, 2, "b", "bard"); | |
| 52 | expect(archer.hp).toBe(rulesOf(c, "archer").hp); | |
| 53 | expect(unknown.cls).toBe("knight"); | |
| 54 | expect(snapshot(w).heroes.map((h) => h.cls)).toEqual(["archer", "knight"]); | |
| 55 | }); | |
| 56 | ||
| 57 | test("the Archer shoots from afar; the Knight has to walk up", () => { | |
| 58 | const c = kitchen(LONG); | |
| 59 | const shoot = (cls: string) => { | |
| 60 | const w = createWorld(c, 1); | |
| 61 | const hero = addHero(c, w, 1, "a", cls); | |
| 62 | const foe = w.monsters[0]!; | |
| 63 | order(w, 1, { type: "attack", target: foe.id }); | |
| 64 | for (let t = 0; t < 400 && w.kills === 0; t++) { | |
| 65 | foe.x = 11.5; // pinned in place, so only reach decides | |
| 66 | foe.z = 1.5; | |
| 67 | step(c, w); | |
| 68 | } | |
| 69 | return { kills: w.kills, x: hero.x }; | |
| 70 | }; | |
| 71 | const archer = shoot("archer"); | |
| 72 | const knight = shoot("knight"); | |
| 73 | expect(archer.kills).toBe(1); | |
| 74 | expect(knight.kills).toBe(1); | |
| 75 | expect(archer.x).toBeLessThan(7); | |
| 76 | expect(knight.x).toBeGreaterThan(9); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a Mage's fireball splashes; a hero's shot never hurts a hero", () => { | |
| 80 | const c = kitchen(["##########", "#S.......#", "#........#", "#.......E#", "##########"]); | |
| 81 | const w = createWorld(c, 1); | |
| 82 | const mage = addHero(c, w, 1, "m", "mage"); | |
| 83 | const friend = addHero(c, w, 2, "f", "knight"); | |
| 84 | mage.x = 1.5; | |
| 85 | mage.z = 2.5; | |
| 86 | friend.x = 3.5; | |
| 87 | friend.z = 2.5; | |
| 88 | friend.invuln = 0; | |
| 89 | for (const [x, z] of [[6, 2.5], [6.5, 3.2]] as const) | |
| 90 | w.monsters.push({ id: w.nextId++, kind: "meatball", x, z, hp: 3, state: "idle", t: 0, dx: 0, dz: 0, cd: 0, hurt: 0, foe: 0, foeT: 0 }); | |
| 91 | mage.fx = 1; | |
| 92 | mage.fz = 0; | |
| 93 | order(w, 1, { type: "attack" }); | |
| 94 | let struck: number[] = []; | |
| 95 | for (let t = 0; t < 40 && struck.length === 0; t++) { | |
| 96 | const hits = step(c, w).filter((h) => h.type === "hit"); | |
| 97 | struck = hits.map((h) => (h.type === "hit" ? h.on : 0)); | |
| 98 | } | |
| 99 | // One fireball, one tick, both monsters. | |
| 100 | expect(struck.length).toBe(2); | |
| 101 | expect(friend.hp).toBe(rulesOf(c, "knight").hp); | |
| 102 | }); | |
| 103 | ||
| 104 | test("Order Up! pulls every monster near onto the Knight, whose lid takes the edge off", () => { | |
| 105 | const c = kitchen(["############", "#S........m#", "#.........E#", "############"]); | |
| 106 | const w = createWorld(c, 1); | |
| 107 | const knight = addHero(c, w, 1, "k", "knight"); | |
| 108 | const mage = addHero(c, w, 2, "m", "mage"); | |
| 109 | const foe = w.monsters[0]!; | |
| 110 | // The Mage stands nearest; the Knight is further off, but shouts. | |
| 111 | mage.x = 8.5; | |
| 112 | mage.z = 2.5; | |
| 113 | knight.x = 6.5; | |
| 114 | knight.z = 1.5; | |
| 115 | mage.invuln = 0; | |
| 116 | knight.invuln = 0; | |
| 117 | foe.x = 9.5; | |
| 118 | foe.z = 2; | |
| 119 | order(w, 1, { type: "ability" }); | |
| 120 | const seen = run(c, w, 60); | |
| 121 | expect(seen).toContain("ability"); | |
| 122 | expect(foe.foe).toBe(knight.id); | |
| 123 | expect(knight.guard).toBeGreaterThan(0); | |
| 124 | // A meatball bites for 1 and the lid blocks 1: the Knight takes nothing, the Mage nothing either. | |
| 125 | expect(seen).toContain("blocked"); | |
| 126 | expect(knight.hp).toBe(rulesOf(c, "knight").hp); | |
| 127 | expect(mage.hp).toBe(rulesOf(c, "mage").hp); | |
| 128 | // Once is all a cooldown allows. | |
| 129 | order(w, 1, { type: "ability" }); | |
| 130 | expect(run(c, w, 1)).not.toContain("ability"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("Skewer Storm looses a fan of skewers", () => { | |
| 134 | const c = kitchen(LONG); | |
| 135 | const w = createWorld(c, 1); | |
| 136 | addHero(c, w, 1, "a", "archer"); | |
| 137 | order(w, 1, { type: "ability" }); | |
| 138 | step(c, w); | |
| 139 | const volley = classOf(c, "archer").ability.count!; | |
| 140 | const mine = w.shots.filter((s) => s.by === 1); | |
| 141 | expect(mine.length).toBe(volley); | |
| 142 | expect(new Set(mine.map((s) => s.dz)).size).toBe(volley); | |
| 143 | expect(snapshot(w).shots[0]).toMatchObject({ by: 1 }); | |
| 144 | }); | |
| 145 | ||
| 146 | test("Soup's On! heals whoever is close and gets the downed up", () => { | |
| 147 | const c = kitchen(["#######", "#S...E#", "#######"]); | |
| 148 | const w = createWorld(c, 1); | |
| 149 | const mage = addHero(c, w, 1, "m", "mage"); | |
| 150 | const archer = addHero(c, w, 2, "a", "archer"); | |
| 151 | const knight = addHero(c, w, 3, "k", "knight"); | |
| 152 | archer.hp = 1; | |
| 153 | knight.hp = 0; | |
| 154 | knight.downed = true; | |
| 155 | for (const h of [archer, knight]) { | |
| 156 | h.x = mage.x + 0.5; | |
| 157 | h.z = mage.z; | |
| 158 | } | |
| 159 | order(w, 1, { type: "ability" }); | |
| 160 | const seen = run(c, w, 1); | |
| 161 | expect(seen).toContain("revived"); | |
| 162 | expect(knight.downed).toBe(false); | |
| 163 | expect(archer.hp).toBe(Math.min(rulesOf(c, "archer").hp, 1 + classOf(c, "mage").ability.heal!)); | |
| 164 | // Nobody is healed past their class's hearts. | |
| 165 | expect(mage.hp).toBe(rulesOf(c, "mage").hp); | |
| 166 | }); | |
| 167 | ||
| 168 | test("the same seed, the same classes, the same orders: the same run", () => { | |
| 169 | const play = () => { | |
| 170 | const w = createWorld(game, 99); | |
| 171 | addHero(game, w, 1, "a", "knight"); | |
| 172 | addHero(game, w, 2, "b", "archer"); | |
| 173 | addHero(game, w, 3, "c", "mage"); | |
| 174 | for (let t = 0; t < 600; t++) { | |
| 175 | if (t % 30 === 0) for (const id of [1, 2, 3]) order(w, id, { type: "attack", target: w.monsters[0]?.id }); | |
| 176 | if (t % 90 === 0) for (const id of [1, 2, 3]) order(w, id, { type: "ability" }); | |
| 177 | step(game, w); | |
| 178 | } | |
| 179 | return JSON.stringify(snapshot(w)); | |
| 180 | }; | |
| 181 | expect(play()).toBe(play()); | |
| 182 | }); | |
| 183 | ||
| 184 | test("a class the sim could not play does not boot", () => { | |
| 185 | const files = (classes: unknown) => ({ | |
| 186 | rules: { tickHz: 20, roomsPerRun: 1, maxTicks: 10, hero: game.rules.hero, score: game.rules.score, hub: { ...game.hub, props: undefined } }, | |
| 187 | monsters: { monsters: game.monsters }, | |
| 188 | traps: { traps: game.traps }, | |
| 189 | loot: { loot: game.loot }, | |
| 190 | rooms: { rooms: game.rooms }, | |
| 191 | hub: { props: game.hub.props }, | |
| 192 | classes, | |
| 193 | }); | |
| 194 | expect(check(files({ classes: game.classes }))).toEqual([]); | |
| 195 | const bad = { | |
| 196 | ...game.classes[1]!, | |
| 197 | id: "bard", | |
| 198 | look: "lute", | |
| 199 | paint: "plaid", | |
| 200 | hero: { hp: -1, charisma: 9 }, | |
| 201 | attack: { kind: "song" }, | |
| 202 | ability: { kind: "heal", name: "x", says: "y", cooldown: 10 }, | |
| 203 | }; | |
| 204 | const problems = check(files({ classes: [...game.classes, bad] })).join("\n"); | |
| 205 | expect(problems).toContain("look must be one of"); | |
| 206 | expect(problems).toContain("paint plaid is not a colour token"); | |
| 207 | expect(problems).toContain("hp must be a positive number"); | |
| 208 | expect(problems).toContain("charisma is not one of"); | |
| 209 | expect(problems).toContain("kind must be one of melee, shot"); | |
| 210 | expect(problems).toContain("radius must be a positive number"); | |
| 211 | expect(check(files({ classes: [] }))).toContain("classes: must be a non-empty list"); | |
| 212 | }); |
src/content.ts +42 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The game's content, as data: `content/*.json` — the rules, the monsters, |
| 3 | * the traps, the loot, the rooms and the hub's furniture — read once at | |
| 3 | * the traps, the loot, the rooms, the classes and the hub's furniture — read once at | |
| 4 | 4 | * boot and checked whole. A new monster, room or chest drop is a change to |
| 5 | 5 | * a JSON file, never to code (a new *brain* is code: `behavior` in |
| 6 | 6 | * `src/sim.ts`). |
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | |
| 12 | 12 | import { createHash } from "node:crypto"; |
| 13 | 13 | import { readFileSync } from "node:fs"; |
| 14 | import type { Content } from "./sim.ts"; | |
| 14 | import { CLASS_STATS, type Content } from "./sim.ts"; | |
| 15 | 15 | import { tokens } from "./tokens.ts"; |
| 16 | 16 | |
| 17 | 17 | export interface HubRules { |
| @@ -38,6 +38,9 @@ export interface Game extends Content { | ||
| 38 | 38 | } |
| 39 | 39 | |
| 40 | 40 | const BEHAVIORS = ["chase", "charge", "shoot"]; |
| 41 | const ATTACKS = ["melee", "shot"]; | |
| 42 | const ABILITIES = ["taunt", "volley", "heal"]; | |
| 43 | const LOOKS = ["sword", "bow", "staff"]; | |
| 41 | 44 | const RESERVED = "#.SEC"; |
| 42 | 45 | |
| 43 | 46 | const isObject = (v: unknown): v is Record<string, unknown> => |
| @@ -54,6 +57,7 @@ export function check(files: { | ||
| 54 | 57 | loot: unknown; |
| 55 | 58 | rooms: unknown; |
| 56 | 59 | hub: unknown; |
| 60 | classes: unknown; | |
| 57 | 61 | }): string[] { |
| 58 | 62 | const problems: string[] = []; |
| 59 | 63 | const say = (where: string, what: string) => problems.push(`${where}: ${what}`); |
| @@ -180,6 +184,40 @@ export function check(files: { | ||
| 180 | 184 | const paints = new Set( |
| 181 | 185 | tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)), |
| 182 | 186 | ); |
| 187 | ||
| 188 | const classes = listOf(files.classes, "classes"); | |
| 189 | ids("classes", classes); | |
| 190 | classes.forEach((c, i) => { | |
| 191 | const where = `classes[${i}]`; | |
| 192 | if (!isObject(c)) return; | |
| 193 | for (const key of ["weapon", "role", "blurb", "dodge"]) | |
| 194 | if (typeof c[key] !== "string" || c[key] === "") say(where, `${key} is missing`); | |
| 195 | if (!LOOKS.includes(String(c.look))) say(where, `look must be one of ${LOOKS.join(", ")}`); | |
| 196 | if (!paints.has(String(c.paint))) say(where, `paint ${String(c.paint)} is not a colour token`); | |
| 197 | const stats = isObject(c.hero) ? c.hero : undefined; | |
| 198 | if (stats === undefined) say(where, "hero must be an object (empty for the base rules)"); | |
| 199 | else | |
| 200 | for (const key of Object.keys(stats)) | |
| 201 | if (!(CLASS_STATS as readonly string[]).includes(key)) | |
| 202 | say(`${where}.hero`, `${key} is not one of ${CLASS_STATS.join(", ")}`); | |
| 203 | else if (!positive(stats[key])) say(`${where}.hero`, `${key} must be a positive number`); | |
| 204 | const attack = isObject(c.attack) ? c.attack : {}; | |
| 205 | if (!ATTACKS.includes(String(attack.kind))) | |
| 206 | say(`${where}.attack`, `kind must be one of ${ATTACKS.join(", ")}`); | |
| 207 | if (attack.kind === "shot") need(`${where}.attack`, attack, ["shotSpeed"]); | |
| 208 | if (attack.splash !== undefined && !positive(attack.splash)) | |
| 209 | say(`${where}.attack`, "splash must be a positive number when given"); | |
| 210 | const ability = isObject(c.ability) ? c.ability : {}; | |
| 211 | if (!ABILITIES.includes(String(ability.kind))) | |
| 212 | say(`${where}.ability`, `kind must be one of ${ABILITIES.join(", ")}`); | |
| 213 | for (const key of ["name", "says"]) | |
| 214 | if (typeof ability[key] !== "string" || ability[key] === "") | |
| 215 | say(`${where}.ability`, `${key} is missing`); | |
| 216 | need(`${where}.ability`, ability, ["cooldown"]); | |
| 217 | if (ability.kind === "taunt") need(`${where}.ability`, ability, ["radius", "ticks", "block"]); | |
| 218 | if (ability.kind === "volley") need(`${where}.ability`, ability, ["count", "spread"]); | |
| 219 | if (ability.kind === "heal") need(`${where}.ability`, ability, ["radius", "heal"]); | |
| 220 | }); | |
| 183 | 221 | const props = listOf(files.hub, "props"); |
| 184 | 222 | ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p))); |
| 185 | 223 | props.forEach((p, i) => { |
| @@ -203,6 +241,7 @@ function load(): Game { | ||
| 203 | 241 | loot: read("loot"), |
| 204 | 242 | rooms: read("rooms"), |
| 205 | 243 | hub: read("hub"), |
| 244 | classes: read("classes"), | |
| 206 | 245 | }; |
| 207 | 246 | const problems = check(files); |
| 208 | 247 | if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`); |
| @@ -214,6 +253,7 @@ function load(): Game { | ||
| 214 | 253 | traps: f.traps!.traps as Game["traps"], |
| 215 | 254 | loot: f.loot!.loot as Game["loot"], |
| 216 | 255 | rooms: f.rooms!.rooms as Game["rooms"], |
| 256 | classes: f.classes!.classes as Game["classes"], | |
| 217 | 257 | hub: { ...hub, props: f.hub!.props as Prop[] }, |
| 218 | 258 | }; |
| 219 | 259 | } |
src/delves.ts +10 −3
| @@ -46,6 +46,8 @@ export interface HeroRow { | ||
| 46 | 46 | readonly delve_id: number; |
| 47 | 47 | readonly author_id: string; |
| 48 | 48 | readonly author_name: string; |
| 49 | /** The class this chef cooks as at this table. */ | |
| 50 | readonly class: string; | |
| 49 | 51 | seen: number; |
| 50 | 52 | } |
| 51 | 53 | |
| @@ -72,7 +74,7 @@ export interface DelveView { | ||
| 72 | 74 | readonly day: string; |
| 73 | 75 | readonly author_id: string; |
| 74 | 76 | readonly host_name: string; |
| 75 | readonly party: readonly { id: number; author_id: string; name: string }[]; | |
| 77 | readonly party: readonly { id: number; author_id: string; name: string; class: string }[]; | |
| 76 | 78 | readonly depth: number; |
| 77 | 79 | readonly rooms: number; |
| 78 | 80 | readonly room: string; |
| @@ -86,6 +88,7 @@ export interface HeroView { | ||
| 86 | 88 | readonly delve_id: number; |
| 87 | 89 | readonly author_id: string; |
| 88 | 90 | readonly author_name: string; |
| 91 | readonly class: string; | |
| 89 | 92 | } |
| 90 | 93 | |
| 91 | 94 | const delves = new Map<number, Delve>(); |
| @@ -117,6 +120,7 @@ export const view = (d: Delve): DelveView => ({ | ||
| 117 | 120 | id: h.id, |
| 118 | 121 | author_id: h.author_id, |
| 119 | 122 | name: h.author_name, |
| 123 | class: h.class, | |
| 120 | 124 | })), |
| 121 | 125 | depth: d.world.depth, |
| 122 | 126 | rooms: d.world.plan.length, |
| @@ -131,6 +135,7 @@ export const heroView = (h: HeroRow): HeroView => ({ | ||
| 131 | 135 | delve_id: h.delve_id, |
| 132 | 136 | author_id: h.author_id, |
| 133 | 137 | author_name: h.author_name, |
| 138 | class: h.class, | |
| 134 | 139 | }); |
| 135 | 140 | |
| 136 | 141 | /** Open today's dungeon for a party. `onEnd` is told once, when it is over. */ |
| @@ -162,21 +167,23 @@ export function open( | ||
| 162 | 167 | return delve; |
| 163 | 168 | } |
| 164 | 169 | |
| 165 | /** A chef takes a place at the table. */ | |
| 170 | /** A chef takes a place at the table, dressed as a class (the default, unless said). */ | |
| 166 | 171 | export function enlist( |
| 167 | 172 | delve: Delve, |
| 168 | 173 | who: { id: string; name: string }, |
| 169 | 174 | now: number, |
| 175 | cls?: string, | |
| 170 | 176 | ): HeroRow { |
| 177 | const hero = addHero(game, delve.world, nextHero, who.name, cls); | |
| 171 | 178 | const row: HeroRow = { |
| 172 | 179 | id: nextHero++, |
| 173 | 180 | delve_id: delve.id, |
| 174 | 181 | author_id: who.id, |
| 175 | 182 | author_name: who.name, |
| 183 | class: hero.cls, | |
| 176 | 184 | seen: now, |
| 177 | 185 | }; |
| 178 | 186 | heroes.set(row.id, row); |
| 179 | addHero(game, delve.world, row.id, who.name); | |
| 180 | 187 | if (!delve.crew.includes(who.name)) delve.crew.push(who.name); |
| 181 | 188 | send(delve); |
| 182 | 189 | return row; |
src/game.test.ts +31
| @@ -189,3 +189,34 @@ test("a finished delve goes on the board with its crew and ghost; an empty one d | ||
| 189 | 189 | expect(one.ok && one.value.ghost).toBe("0,15,55;0,20,55"); |
| 190 | 190 | expect(failed(listRuns(db, anon, { day: "yesterday" }))).toBe("invalid"); |
| 191 | 191 | }); |
| 192 | ||
| 193 | test("the dressing room: a class the content knows, worn into the next delve", () => { | |
| 194 | const me = chef(); | |
| 195 | const entered = enterHub(db, me); | |
| 196 | if (!entered.ok) throw new Error(entered.message); | |
| 197 | const id = entered.value.id; | |
| 198 | expect(entered.value.class).toBe("knight"); | |
| 199 | expect(failed(updateAvatar(db, me, { id, class: "bard" }))).toBe("invalid"); | |
| 200 | expect(failed(updateAvatar(db, chef(), { id, class: "mage" }))).toBe("forbidden"); | |
| 201 | const dressed = updateAvatar(db, me, { id, class: "archer" }); | |
| 202 | expect(dressed.ok && dressed.value.class).toBe("archer"); | |
| 203 | // Wandering off keeps the outfit: the wardrobe remembers. | |
| 204 | sweepHub(Date.now() + game.hub.idleMinutes * 60_000 + 1); | |
| 205 | expect(hub.outfitOf(me.id)).toBe("archer"); | |
| 206 | const opened = openDelve(db, me); | |
| 207 | if (!opened.ok) throw new Error(opened.message); | |
| 208 | expect(opened.value.party[0]?.class).toBe("archer"); | |
| 209 | expect(kitchen.delveOf(opened.value.id)!.world.heroes[0]!.cls).toBe("archer"); | |
| 210 | }); | |
| 211 | ||
| 212 | test("a seat can be taken as a class; a class nobody voted in cannot", () => { | |
| 213 | const host = chef(); | |
| 214 | const opened = openDelve(db, host, { class: "mage" }); | |
| 215 | if (!opened.ok) throw new Error(opened.message); | |
| 216 | expect(opened.value.party[0]?.class).toBe("mage"); | |
| 217 | expect(failed(openDelve(db, chef(), { class: "necromancer" }))).toBe("invalid"); | |
| 218 | const joined = joinDelve(db, chef(), { delve: opened.value.id, class: "knight" }); | |
| 219 | expect(joined.ok && joined.value.class).toBe("knight"); | |
| 220 | expect(failed(joinDelve(db, chef(), { delve: opened.value.id, class: 7 }))).toBe("invalid"); | |
| 221 | expect(readOrder({ type: "ability" }, { w: 5, h: 5 })).toEqual({ type: "ability" }); | |
| 222 | }); |
src/hub.ts +18
| @@ -18,6 +18,8 @@ export interface Avatar { | ||
| 18 | 18 | readonly id: number; |
| 19 | 19 | readonly author_id: string; |
| 20 | 20 | readonly name: string; |
| 21 | /** The class it is dressed as, for the next delve. */ | |
| 22 | class: string; | |
| 21 | 23 | x: number; |
| 22 | 24 | z: number; |
| 23 | 25 | tx: number; |
| @@ -37,6 +39,7 @@ export interface AvatarView { | ||
| 37 | 39 | readonly id: number; |
| 38 | 40 | readonly author_id: string; |
| 39 | 41 | readonly name: string; |
| 42 | readonly class: string; | |
| 40 | 43 | readonly x: number; |
| 41 | 44 | readonly z: number; |
| 42 | 45 | readonly tx: number; |
| @@ -59,6 +62,8 @@ const CHAT_KEEP = 30; | ||
| 59 | 62 | const { width, depth, speed, idleMinutes } = game.hub; |
| 60 | 63 | |
| 61 | 64 | const avatars = new Map<number, Avatar>(); |
| 65 | /** What each person last changed into: kept when they wander off, so a delve still knows. */ | |
| 66 | const wardrobe = new Map<string, string>(); | |
| 62 | 67 | const chat: Line[] = []; |
| 63 | 68 | let nextId = 1; |
| 64 | 69 | |
| @@ -81,6 +86,7 @@ export const view = (a: Avatar, now: number): AvatarView => ({ | ||
| 81 | 86 | id: a.id, |
| 82 | 87 | author_id: a.author_id, |
| 83 | 88 | name: a.name, |
| 89 | class: a.class, | |
| 84 | 90 | x: round(a.x), |
| 85 | 91 | z: round(a.z), |
| 86 | 92 | tx: round(a.tx), |
| @@ -98,6 +104,10 @@ export const ofAuthor = (authorId: string): Avatar | undefined => | ||
| 98 | 104 | all().find((a) => a.author_id === authorId); |
| 99 | 105 | export const chatLog = (): readonly Line[] => chat; |
| 100 | 106 | |
| 107 | /** The class this person last dressed as, or the default. */ | |
| 108 | export const outfitOf = (authorId: string): string => | |
| 109 | wardrobe.get(authorId) ?? game.classes[0]!.id; | |
| 110 | ||
| 101 | 111 | /** Someone steps into the square — or is already there, and stays put. */ |
| 102 | 112 | export function place(authorId: string, name: string, now: number): Avatar { |
| 103 | 113 | const there = ofAuthor(authorId); |
| @@ -113,6 +123,7 @@ export function place(authorId: string, name: string, now: number): Avatar { | ||
| 113 | 123 | id, |
| 114 | 124 | author_id: authorId, |
| 115 | 125 | name, |
| 126 | class: outfitOf(authorId), | |
| 116 | 127 | x, |
| 117 | 128 | z, |
| 118 | 129 | tx: x, |
| @@ -145,6 +156,13 @@ export function emote(a: Avatar, name: string, now: number): void { | ||
| 145 | 156 | a.seen = now; |
| 146 | 157 | } |
| 147 | 158 | |
| 159 | /** Change in the dressing room: the next delve is cooked as this class. */ | |
| 160 | export function dress(a: Avatar, cls: string, now: number): void { | |
| 161 | a.class = cls; | |
| 162 | a.seen = now; | |
| 163 | wardrobe.set(a.author_id, cls); | |
| 164 | } | |
| 165 | ||
| 148 | 166 | export function say(a: Avatar, text: string, now: number): void { |
| 149 | 167 | a.say = text; |
| 150 | 168 | a.sayAt = now; |
src/pages/delve.ts +8 −2
| @@ -11,6 +11,7 @@ import type { DelveView, HeroView } from "../delves.ts"; | ||
| 11 | 11 | import { publicOrigin } from "../env.ts"; |
| 12 | 12 | import { escape } from "../http.ts"; |
| 13 | 13 | import type { Who } from "../who.ts"; |
| 14 | import { classOf } from "../sim.ts"; | |
| 14 | 15 | import { layout, toast } from "./layout.ts"; |
| 15 | 16 | |
| 16 | 17 | export interface DelvePage { |
| @@ -35,12 +36,14 @@ export function delvePage(p: DelvePage): string { | ||
| 35 | 36 | const path = `/delve/${delve.id}`; |
| 36 | 37 | const room = game.rooms.find((r) => r.id === delve.room); |
| 37 | 38 | const over = delve.over; |
| 39 | const mine = hero === undefined ? undefined : classOf(game, hero.class); | |
| 38 | 40 | const pad = |
| 39 | 41 | hero === undefined || over !== null |
| 40 | 42 | ? "" |
| 41 | : `<div class="pad"> | |
| 43 | : `<div class="pad pad-three"> | |
| 42 | 44 | <button class="button button-big" type="button" data-act="attack">Attack</button> |
| 43 | 45 | <button class="button button-big button-alt" type="button" data-act="dodge">Dodge</button> |
| 46 | <button class="button button-big button-quiet" type="button" data-act="ability">${escape(mine!.ability.name)}</button> | |
| 44 | 47 | </div>`; |
| 45 | 48 | return layout( |
| 46 | 49 | `<h2>${escape(delve.host_name)}'s table <small class="muted mono">${escape(delve.day)}</small></h2> |
| @@ -50,8 +53,10 @@ export function delvePage(p: DelvePage): string { | ||
| 50 | 53 | <div class="tags" aria-hidden="true"></div> |
| 51 | 54 | <div class="hud"> |
| 52 | 55 | <p data-hud="room">${escape(room?.name ?? delve.room)} · floor ${delve.depth + 1} of ${delve.rooms}</p> |
| 56 | <p data-hud="class">${mine === undefined ? "" : `${escape(mine.name)} · ${escape(mine.weapon)}`}</p> | |
| 53 | 57 | <p data-hud="hp">${hero === undefined ? "Watching" : ""}</p> |
| 54 | 58 | <p data-hud="score">${delve.score} pts</p> |
| 59 | <p data-hud="party">${delve.party.map((h) => `${escape(h.name)} (${escape(classOf(game, h.class).name)})`).join(" · ")}</p> | |
| 55 | 60 | <p data-hud="ghost"></p> |
| 56 | 61 | </div> |
| 57 | 62 | <div class="over" id="over"${over === null ? " hidden" : ""}> |
| @@ -75,6 +80,7 @@ export function delvePage(p: DelvePage): string { | ||
| 75 | 80 | <li><b>Walk</b>: click or tap the floor, or WASD / arrow keys.</li> |
| 76 | 81 | <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li> |
| 77 | 82 | <li><b>Dodge</b>: shift-click or right-click where to roll, or <kbd>K</kbd>. Rolling dodges bites, charges, seeds and spikes.</li> |
| 83 | <li><b>Signature move</b>: <kbd>Q</kbd> or the third button. ${game.classes.map((c) => `<b>${escape(c.name)}</b>: ${escape(c.ability.name)}`).join(" · ")}. Change class in the hub's dressing room between delves.</li> | |
| 78 | 84 | <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li> |
| 79 | 85 | <li>Clear a room and the door opens. Five floors, then the Pass.</li> |
| 80 | 86 | </ul> |
| @@ -91,7 +97,7 @@ export function delvePage(p: DelvePage): string { | ||
| 91 | 97 | function party(p: DelvePage, path: string): string { |
| 92 | 98 | const { who, delve, hero } = p; |
| 93 | 99 | const names = delve.party |
| 94 | .map((h) => `<li>${escape(h.name)}${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`) | |
| 100 | .map((h) => `<li>${escape(h.name)} <small class="muted">${escape(classOf(game, h.class).name)}</small>${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`) | |
| 95 | 101 | .join(""); |
| 96 | 102 | let action = ""; |
| 97 | 103 | if (delve.over === null) { |
src/pages/hub.ts +34
| @@ -25,6 +25,8 @@ export interface HubPage { | ||
| 25 | 25 | readonly notes: readonly Note[]; |
| 26 | 26 | /** The hero this person is playing right now, if any. */ |
| 27 | 27 | readonly playing?: HeroView | undefined; |
| 28 | /** The class this person wears into their next delve. */ | |
| 29 | readonly outfit: string; | |
| 28 | 30 | readonly notice?: string | undefined; |
| 29 | 31 | } |
| 30 | 32 | |
| @@ -43,6 +45,7 @@ export function hubPage(p: HubPage): string { | ||
| 43 | 45 | </div> |
| 44 | 46 | </section> |
| 45 | 47 | ${controls(p)} |
| 48 | ${dressingRoom(p)} | |
| 46 | 49 | <div class="grid"> |
| 47 | 50 | ${tables(p)} |
| 48 | 51 | ${board(p)} |
| @@ -84,6 +87,37 @@ function controls(p: HubPage): string { | ||
| 84 | 87 | </section>`; |
| 85 | 88 | } |
| 86 | 89 | |
| 90 | const seconds = (ticks: number): string => | |
| 91 | `${Math.round((ticks / game.rules.tickHz) * 10) / 10}s`; | |
| 92 | ||
| 93 | /** Cheffry's dressing room: every class, and a button to change into it. */ | |
| 94 | function dressingRoom(p: HubPage): string { | |
| 95 | const signedIn = p.who.role !== "anon"; | |
| 96 | const cards = game.classes | |
| 97 | .map((c) => { | |
| 98 | const hp = { ...game.rules.hero, ...c.hero }.hp; | |
| 99 | const on = signedIn && c.id === p.outfit; | |
| 100 | return `<li class="class-card"> | |
| 101 | <p class="class-name"><b>${escape(c.name)}</b> <small class="muted">${escape(c.role)}</small></p> | |
| 102 | <p>${escape(c.blurb)}</p> | |
| 103 | <ul class="list-plain class-facts"> | |
| 104 | <li><b>Weapon</b> ${escape(c.weapon)}</li> | |
| 105 | <li><b>Hearts</b> ${"♥".repeat(hp)}</li> | |
| 106 | <li><b>Dodge</b> ${escape(c.dodge)}</li> | |
| 107 | <li><b>${escape(c.ability.name)}</b> ${escape(c.ability.says)} <small class="muted">every ${seconds(c.ability.cooldown)}</small></li> | |
| 108 | </ul> | |
| 109 | ${signedIn ? `<button class="button${on ? "" : " button-quiet"}" name="class" value="${escape(c.id)}" data-name="${escape(c.name)}" aria-pressed="${on}">${on ? "Wearing it" : `Wear the ${escape(c.name)}`}</button>` : ""} | |
| 110 | </li>`; | |
| 111 | }) | |
| 112 | .join(""); | |
| 113 | const body = `<ul class="class-list">${cards}</ul>`; | |
| 114 | return `<section class="card" id="dressing-room" aria-labelledby="dressing-room-title"> | |
| 115 | <h2 id="dressing-room-title">Cheffry's dressing room</h2> | |
| 116 | <p class="muted">Community vote, called by Ben Versluys: pick a class. Swap between delves; you keep what you wear until you change. Q (or the Ability button) is your signature move in the dungeon.</p> | |
| 117 | ${signedIn ? `<form method="post" action="/hub/dress" data-hub="dress" aria-label="Classes">${body}</form>` : `${body}<p class="muted"><a href="/__forge/signin?to=/">Sign in</a> to try something on.</p>`} | |
| 118 | </section>`; | |
| 119 | } | |
| 120 | ||
| 87 | 121 | function tables(p: HubPage): string { |
| 88 | 122 | const open = p.delves.filter((d) => d.over === null); |
| 89 | 123 | const rows = open |
src/routes.ts +44 −10
| @@ -61,7 +61,7 @@ import { | ||
| 61 | 61 | staticFile, |
| 62 | 62 | text, |
| 63 | 63 | } from "./http.ts"; |
| 64 | import { SAY_MAX } from "./hub.ts"; | |
| 64 | import { outfitOf, SAY_MAX } from "./hub.ts"; | |
| 65 | 65 | import { events } from "./live.ts"; |
| 66 | 66 | import { rpc } from "./mcp.ts"; |
| 67 | 67 | import { delvePage } from "./pages/delve.ts"; |
| @@ -149,6 +149,7 @@ export const AVATAR: Schema = { | ||
| 149 | 149 | id: { type: "integer" }, |
| 150 | 150 | author_id: { type: "string" }, |
| 151 | 151 | name: { type: "string" }, |
| 152 | class: { enum: game.classes.map((c) => c.id) }, | |
| 152 | 153 | x: { type: "number" }, |
| 153 | 154 | z: { type: "number" }, |
| 154 | 155 | tx: { type: "number" }, |
| @@ -159,18 +160,23 @@ export const AVATAR: Schema = { | ||
| 159 | 160 | say: { type: ["string", "null"] }, |
| 160 | 161 | sayAge: { type: "integer" }, |
| 161 | 162 | }, |
| 162 | required: ["id", "author_id", "name", "x", "z", "tx", "tz", "age"], | |
| 163 | required: ["id", "author_id", "name", "class", "x", "z", "tx", "tz", "age"], | |
| 163 | 164 | }; |
| 164 | 165 | const AVATAR_CHANGE: Schema = { |
| 165 | 166 | type: "object", |
| 166 | description: "Any of: where to walk (x and z together), an emote, words.", | |
| 167 | description: "Any of: where to walk (x and z together), an emote, words, a class to wear into the next delve.", | |
| 167 | 168 | properties: { |
| 168 | 169 | x: { type: "number" }, |
| 169 | 170 | z: { type: "number" }, |
| 170 | 171 | emote: { enum: [...game.hub.emotes] }, |
| 171 | 172 | say: { type: "string", minLength: 1, maxLength: SAY_MAX }, |
| 173 | class: { enum: game.classes.map((c) => c.id) }, | |
| 172 | 174 | }, |
| 173 | 175 | }; |
| 176 | const AS_CLASS: Schema = { | |
| 177 | ...{ enum: game.classes.map((c) => c.id) }, | |
| 178 | description: "Which class to cook as; what you wear in the hub when left out.", | |
| 179 | }; | |
| 174 | 180 | export const DELVE: Schema = { |
| 175 | 181 | type: "object", |
| 176 | 182 | properties: { |
| @@ -187,6 +193,7 @@ export const DELVE: Schema = { | ||
| 187 | 193 | id: { type: "integer" }, |
| 188 | 194 | author_id: { type: "string" }, |
| 189 | 195 | name: { type: "string" }, |
| 196 | class: { enum: game.classes.map((c) => c.id) }, | |
| 190 | 197 | }, |
| 191 | 198 | }, |
| 192 | 199 | }, |
| @@ -206,15 +213,16 @@ export const HERO: Schema = { | ||
| 206 | 213 | delve_id: { type: "integer" }, |
| 207 | 214 | author_id: { type: "string" }, |
| 208 | 215 | author_name: { type: "string" }, |
| 216 | class: { enum: game.classes.map((c) => c.id) }, | |
| 209 | 217 | }, |
| 210 | required: ["id", "delve_id", "author_id", "author_name"], | |
| 218 | required: ["id", "delve_id", "author_id", "author_name", "class"], | |
| 211 | 219 | }; |
| 212 | 220 | const ORDER: Schema = { |
| 213 | 221 | type: "object", |
| 214 | 222 | description: |
| 215 | "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). stop.", | |
| 223 | "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). ability: your class's signature move. stop.", | |
| 216 | 224 | properties: { |
| 217 | type: { enum: ["move", "attack", "dodge", "stop"] }, | |
| 225 | type: { enum: ["move", "attack", "dodge", "ability", "stop"] }, | |
| 218 | 226 | x: { type: "number" }, |
| 219 | 227 | z: { type: "number" }, |
| 220 | 228 | target: { type: "integer" }, |
| @@ -312,6 +320,7 @@ export const routes: readonly Route[] = [ | ||
| 312 | 320 | notes: or(listNotes(c.db, c.who, { limit: 50 })), |
| 313 | 321 | playing: |
| 314 | 322 | c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id), |
| 323 | outfit: c.who.role === "anon" ? game.classes[0]!.id : outfitOf(c.who.id), | |
| 315 | 324 | notice, |
| 316 | 325 | }), |
| 317 | 326 | 200, |
| @@ -399,6 +408,22 @@ export const routes: readonly Route[] = [ | ||
| 399 | 408 | ); |
| 400 | 409 | }, |
| 401 | 410 | }, |
| 411 | { | |
| 412 | method: "POST", | |
| 413 | path: "/hub/dress", | |
| 414 | handle: async (c) => { | |
| 415 | const form = await readForm(c.request); | |
| 416 | if (form === undefined) return text("too large", 413); | |
| 417 | const me = enterHub(c.db, c.who); | |
| 418 | if (!me.ok) return done(me); | |
| 419 | return done( | |
| 420 | updateAvatar(c.db, c.who, { | |
| 421 | id: me.value.id, | |
| 422 | class: String(form.get("class") ?? ""), | |
| 423 | }), | |
| 424 | ); | |
| 425 | }, | |
| 426 | }, | |
| 402 | 427 | { |
| 403 | 428 | method: "POST", |
| 404 | 429 | path: "/delves", |
| @@ -550,11 +575,12 @@ export const routes: readonly Route[] = [ | ||
| 550 | 575 | z: f.z, |
| 551 | 576 | emote: f.emote, |
| 552 | 577 | say: f.say, |
| 578 | class: f.class, | |
| 553 | 579 | }), |
| 554 | 580 | ); |
| 555 | 581 | }, |
| 556 | 582 | api: { |
| 557 | summary: "Walk somewhere, emote, or say something.", | |
| 583 | summary: "Walk somewhere, emote, say something, or change class in the dressing room.", | |
| 558 | 584 | body: AVATAR_CHANGE, |
| 559 | 585 | answer: AVATAR, |
| 560 | 586 | }, |
| @@ -576,9 +602,14 @@ export const routes: readonly Route[] = [ | ||
| 576 | 602 | { |
| 577 | 603 | method: "POST", |
| 578 | 604 | path: "/api/delves", |
| 579 | handle: (c) => answer(openDelve(c.db, c.who), 201), | |
| 605 | handle: async (c) => { | |
| 606 | const body = await readJson(c.request); | |
| 607 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 608 | return answer(openDelve(c.db, c.who, { class: fields(body).class }), 201); | |
| 609 | }, | |
| 580 | 610 | api: { |
| 581 | 611 | summary: "Open today's dungeon with yourself at the table.", |
| 612 | body: { type: "object", properties: { class: AS_CLASS } }, | |
| 582 | 613 | answer: DELVE, |
| 583 | 614 | status: 201, |
| 584 | 615 | }, |
| @@ -605,7 +636,10 @@ export const routes: readonly Route[] = [ | ||
| 605 | 636 | const body = await readJson(c.request); |
| 606 | 637 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 607 | 638 | return answer( |
| 608 | joinDelve(c.db, c.who, { delve: Number(fields(body).delve) }), | |
| 639 | joinDelve(c.db, c.who, { | |
| 640 | delve: Number(fields(body).delve), | |
| 641 | class: fields(body).class, | |
| 642 | }), | |
| 609 | 643 | 201, |
| 610 | 644 | ); |
| 611 | 645 | }, |
| @@ -613,7 +647,7 @@ export const routes: readonly Route[] = [ | ||
| 613 | 647 | summary: "Take a seat at a table (four a table).", |
| 614 | 648 | body: { |
| 615 | 649 | type: "object", |
| 616 | properties: { delve: { type: "integer" } }, | |
| 650 | properties: { delve: { type: "integer" }, class: AS_CLASS }, | |
| 617 | 651 | required: ["delve"], |
| 618 | 652 | }, |
| 619 | 653 | answer: HERO, |
src/server.test.ts +21
| @@ -426,6 +426,27 @@ test("the hub over the API: enter, walk, emote; a stranger watches", async () => | ||
| 426 | 426 | expect(await (await fetch(base + "/")).text()).toContain("yes chef"); |
| 427 | 427 | }); |
| 428 | 428 | |
| 429 | test("the dressing room works without script, and the table shows the class", async () => { | |
| 430 | const dresser = as("u61", "dresser"); | |
| 431 | const guest = await (await fetch(base + "/")).text(); | |
| 432 | expect(guest).toContain("Cheffry's dressing room"); | |
| 433 | expect(guest).not.toContain('action="/hub/dress"'); | |
| 434 | const form = new FormData(); | |
| 435 | form.set("class", "mage"); | |
| 436 | const dressed = await api("/hub/dress", { method: "POST", body: form, redirect: "manual" }, dresser); | |
| 437 | expect(dressed.status).toBe(303); | |
| 438 | const hubPage = await (await api("/", {}, dresser)).text(); | |
| 439 | expect(hubPage).toMatch(/value="mage"[^>]*aria-pressed="true"/); | |
| 440 | const started = await api("/delves", { method: "POST", redirect: "manual" }, dresser); | |
| 441 | const table = await (await api(started.headers.get("location")!, {}, dresser)).text(); | |
| 442 | expect(table).toContain('data-act="ability"'); | |
| 443 | expect(table).toContain("Soup's On!"); | |
| 444 | const opened = await api("/api/delves", { method: "POST", ...jsonBody({ class: "archer" }) }, dresser); | |
| 445 | expect(opened.status).toBe(201); | |
| 446 | expect(((await opened.json()) as { party: { class: string }[] }).party[0]!.class).toBe("archer"); | |
| 447 | expect((await api("/api/delves", { method: "POST", ...jsonBody({ class: "bard" }) }, dresser)).status).toBe(422); | |
| 448 | }); | |
| 449 | ||
| 429 | 450 | test("the sim and the content are served by content, cached for a year", async () => { |
| 430 | 451 | const page = await (await api("/delve/1", {}, alice)).text(); |
| 431 | 452 | const sim = /data-sim="(\/sim\.js\?v=[0-9a-f]{8})"/.exec(page)?.[1]; |
src/sim.ts +205 −8
| @@ -71,6 +71,47 @@ export interface RoomDef { | ||
| 71 | 71 | readonly final?: boolean; |
| 72 | 72 | readonly map: readonly string[]; |
| 73 | 73 | } |
| 74 | /** The numbers a class may change about a hero. */ | |
| 75 | export const CLASS_STATS = [ | |
| 76 | "hp", "speed", "damage", "reach", "swing", "cooldown", | |
| 77 | "dodgeSpeed", "dodgeTicks", "dodgeCooldown", | |
| 78 | ] as const; | |
| 79 | export type ClassStats = Pick<HeroRules, (typeof CLASS_STATS)[number]>; | |
| 80 | export interface AbilityDef { | |
| 81 | readonly kind: "taunt" | "volley" | "heal"; | |
| 82 | readonly name: string; | |
| 83 | readonly says: string; | |
| 84 | readonly cooldown: number; | |
| 85 | /** taunt and heal: how far it reaches. */ | |
| 86 | readonly radius?: number; | |
| 87 | /** taunt: how long monsters stay angry, and the lid stays up. */ | |
| 88 | readonly ticks?: number; | |
| 89 | /** taunt: damage the lid takes off each hit. */ | |
| 90 | readonly block?: number; | |
| 91 | /** volley: how many shots, and how wide the fan. */ | |
| 92 | readonly count?: number; | |
| 93 | readonly spread?: number; | |
| 94 | /** heal: hearts back. */ | |
| 95 | readonly heal?: number; | |
| 96 | } | |
| 97 | export interface ClassDef { | |
| 98 | readonly id: string; | |
| 99 | readonly name: string; | |
| 100 | readonly weapon: string; | |
| 101 | readonly role: string; | |
| 102 | readonly blurb: string; | |
| 103 | /** How its dodge feels, in words. */ | |
| 104 | readonly dodge: string; | |
| 105 | readonly look: "sword" | "bow" | "staff"; | |
| 106 | readonly paint: string; | |
| 107 | readonly hero: Partial<ClassStats>; | |
| 108 | readonly attack: { | |
| 109 | readonly kind: "melee" | "shot"; | |
| 110 | readonly shotSpeed?: number; | |
| 111 | readonly splash?: number; | |
| 112 | }; | |
| 113 | readonly ability: AbilityDef; | |
| 114 | } | |
| 74 | 115 | export interface Content { |
| 75 | 116 | readonly rules: { |
| 76 | 117 | readonly tickHz: number; |
| @@ -83,6 +124,7 @@ export interface Content { | ||
| 83 | 124 | readonly traps: readonly TrapDef[]; |
| 84 | 125 | readonly loot: readonly LootDef[]; |
| 85 | 126 | readonly rooms: readonly RoomDef[]; |
| 127 | readonly classes: readonly ClassDef[]; | |
| 86 | 128 | } |
| 87 | 129 | |
| 88 | 130 | // ── The world ── |
| @@ -90,6 +132,8 @@ export interface Content { | ||
| 90 | 132 | export interface Hero { |
| 91 | 133 | readonly id: number; |
| 92 | 134 | readonly name: string; |
| 135 | /** Which class this chef cooks as: a `ClassDef` id. */ | |
| 136 | readonly cls: string; | |
| 93 | 137 | x: number; |
| 94 | 138 | z: number; |
| 95 | 139 | fx: number; |
| @@ -109,6 +153,11 @@ export interface Hero { | ||
| 109 | 153 | invuln: number; |
| 110 | 154 | downed: boolean; |
| 111 | 155 | revive: number; |
| 156 | wantAbility: boolean; | |
| 157 | /** Ticks until the signature move is ready again. */ | |
| 158 | ability: number; | |
| 159 | /** Ticks the Knight's lid stays up. */ | |
| 160 | guard: number; | |
| 112 | 161 | } |
| 113 | 162 | export interface Monster { |
| 114 | 163 | readonly id: number; |
| @@ -122,6 +171,9 @@ export interface Monster { | ||
| 122 | 171 | dz: number; |
| 123 | 172 | cd: number; |
| 124 | 173 | hurt: number; |
| 174 | /** A hero who taunted it, and for how many ticks more it cares. */ | |
| 175 | foe: number; | |
| 176 | foeT: number; | |
| 125 | 177 | } |
| 126 | 178 | export interface Shot { |
| 127 | 179 | readonly id: number; |
| @@ -131,6 +183,10 @@ export interface Shot { | ||
| 131 | 183 | readonly dz: number; |
| 132 | 184 | ttl: number; |
| 133 | 185 | readonly damage: number; |
| 186 | /** The hero who loosed it; 0 for a monster's seed. */ | |
| 187 | readonly by: number; | |
| 188 | /** A hero's shot hurts every monster this close to where it lands. */ | |
| 189 | readonly splash: number; | |
| 134 | 190 | } |
| 135 | 191 | export interface Chest { |
| 136 | 192 | readonly id: number; |
| @@ -161,6 +217,8 @@ export type Happening = | ||
| 161 | 217 | | { readonly type: "hurt"; readonly hero: number; readonly damage: number } |
| 162 | 218 | | { readonly type: "down"; readonly hero: number } |
| 163 | 219 | | { readonly type: "revived"; readonly hero: number } |
| 220 | | { readonly type: "blocked"; readonly hero: number } | |
| 221 | | { readonly type: "ability"; readonly hero: number; readonly kind: AbilityDef["kind"] } | |
| 164 | 222 | | { readonly type: "slain"; readonly monster: number; readonly kind: string } |
| 165 | 223 | | { readonly type: "loot"; readonly hero: number; readonly loot: string } |
| 166 | 224 | | { readonly type: "open" } |
| @@ -192,6 +250,7 @@ export type Order = | ||
| 192 | 250 | | { readonly type: "move"; readonly x: number; readonly z: number } |
| 193 | 251 | | { readonly type: "attack"; readonly target?: number | undefined } |
| 194 | 252 | | { readonly type: "dodge"; readonly x: number; readonly z: number } |
| 253 | | { readonly type: "ability" } | |
| 195 | 254 | | { readonly type: "stop" }; |
| 196 | 255 | |
| 197 | 256 | // ── Randomness: mulberry32, its state kept in the world ── |
| @@ -277,6 +336,16 @@ const roomById = (content: Content, id: string): RoomDef => { | ||
| 277 | 336 | const monsterDef = (content: Content, kind: string): MonsterDef => |
| 278 | 337 | content.monsters.find((m) => m.id === kind)!; |
| 279 | 338 | |
| 339 | /** A class by id; the first one (the default) for anything else. */ | |
| 340 | export const classOf = (content: Content, id: string): ClassDef => | |
| 341 | content.classes.find((c) => c.id === id) ?? content.classes[0]!; | |
| 342 | ||
| 343 | /** The rules a hero plays by: the base rules, with its class's numbers laid over them. */ | |
| 344 | export const rulesOf = (content: Content, cls: string): HeroRules => ({ | |
| 345 | ...content.rules.hero, | |
| 346 | ...classOf(content, cls).hero, | |
| 347 | }); | |
| 348 | ||
| 280 | 349 | // ── The world, made and changed ── |
| 281 | 350 | |
| 282 | 351 | export function createWorld(content: Content, seed: number): World { |
| @@ -322,6 +391,8 @@ function enter(content: Content, world: World, depth: number): void { | ||
| 322 | 391 | dz: 0, |
| 323 | 392 | cd: Math.floor(random(world) * 30), |
| 324 | 393 | hurt: 0, |
| 394 | foe: 0, | |
| 395 | foeT: 0, | |
| 325 | 396 | })); |
| 326 | 397 | world.chests = world.layout.chests.map((c) => ({ |
| 327 | 398 | id: world.nextId++, |
| @@ -350,17 +421,23 @@ function place(world: World, hero: Hero, i: number): void { | ||
| 350 | 421 | hero.fz = 0; |
| 351 | 422 | } |
| 352 | 423 | |
| 353 | /** A hero steps in. Whoever joins a delve under way starts at this floor's start. */ | |
| 424 | /** | |
| 425 | * A hero steps in, as a class (the first, unless said). Whoever joins a | |
| 426 | * delve under way starts at this floor's start. | |
| 427 | */ | |
| 354 | 428 | export function addHero( |
| 355 | 429 | content: Content, |
| 356 | 430 | world: World, |
| 357 | 431 | id: number, |
| 358 | 432 | name: string, |
| 433 | cls?: string, | |
| 359 | 434 | ): Hero { |
| 360 | const rules = content.rules.hero; | |
| 435 | const kind = classOf(content, cls ?? "").id; | |
| 436 | const rules = rulesOf(content, kind); | |
| 361 | 437 | const hero: Hero = { |
| 362 | 438 | id, |
| 363 | 439 | name, |
| 440 | cls: kind, | |
| 364 | 441 | x: 0, |
| 365 | 442 | z: 0, |
| 366 | 443 | fx: 1, |
| @@ -380,6 +457,9 @@ export function addHero( | ||
| 380 | 457 | invuln: rules.invuln, |
| 381 | 458 | downed: false, |
| 382 | 459 | revive: 0, |
| 460 | wantAbility: false, | |
| 461 | ability: 0, | |
| 462 | guard: 0, | |
| 383 | 463 | }; |
| 384 | 464 | place(world, hero, world.heroes.length); |
| 385 | 465 | world.heroes.push(hero); |
| @@ -403,6 +483,7 @@ export function order(world: World, heroId: number, o: Order): void { | ||
| 403 | 483 | hero.goal = null; |
| 404 | 484 | } else hero.wantSwing = true; |
| 405 | 485 | } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z }; |
| 486 | else if (o.type === "ability") hero.wantAbility = true; | |
| 406 | 487 | else { |
| 407 | 488 | hero.goal = null; |
| 408 | 489 | hero.target = null; |
| @@ -464,10 +545,10 @@ export function step(content: Content, world: World): Happening[] { | ||
| 464 | 545 | world.happened = []; |
| 465 | 546 | if (world.over !== null) return world.happened; |
| 466 | 547 | world.tick++; |
| 467 | const rules = content.rules.hero; | |
| 468 | for (const hero of world.heroes) stepHero(content, world, hero, rules); | |
| 548 | for (const hero of world.heroes) | |
| 549 | stepHero(content, world, hero, rulesOf(content, hero.cls)); | |
| 469 | 550 | for (const monster of world.monsters) stepMonster(content, world, monster); |
| 470 | stepShots(world); | |
| 551 | stepShots(content, world); | |
| 471 | 552 | stepTraps(content, world); |
| 472 | 553 | |
| 473 | 554 | const slain = world.monsters.filter((m) => m.hp <= 0); |
| @@ -534,8 +615,11 @@ function stepHero( | ||
| 534 | 615 | if (hero.dodgeCd > 0) hero.dodgeCd--; |
| 535 | 616 | if (hero.invuln > 0) hero.invuln--; |
| 536 | 617 | if (hero.swing > 0) hero.swing--; |
| 618 | if (hero.ability > 0) hero.ability--; | |
| 619 | if (hero.guard > 0) hero.guard--; | |
| 537 | 620 | |
| 538 | 621 | if (hero.downed) { |
| 622 | hero.wantAbility = false; | |
| 539 | 623 | // A teammate standing close brings a downed chef back. |
| 540 | 624 | const helped = world.heroes.some( |
| 541 | 625 | (h) => h !== hero && !h.downed && dist(h, hero) < 1.3, |
| @@ -564,6 +648,10 @@ function stepHero( | ||
| 564 | 648 | } |
| 565 | 649 | hero.wantDodge = null; |
| 566 | 650 | } |
| 651 | if (hero.wantAbility) { | |
| 652 | hero.wantAbility = false; | |
| 653 | if (hero.ability === 0 && hero.dodge === 0) cast(content, world, hero, rules); | |
| 654 | } | |
| 567 | 655 | if (hero.dodge > 0) { |
| 568 | 656 | hero.dodge--; |
| 569 | 657 | hero.invuln = Math.max(hero.invuln, 1); |
| @@ -623,6 +711,10 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo | ||
| 623 | 711 | hero.swing = rules.swing; |
| 624 | 712 | hero.cooldown = rules.cooldown; |
| 625 | 713 | const damage = rules.damage + hero.power; |
| 714 | if (classOf(content, hero.cls).attack.kind === "shot") { | |
| 715 | loose(content, world, hero, rules, { x: hero.fx, z: hero.fz }); | |
| 716 | return; | |
| 717 | } | |
| 626 | 718 | for (const m of world.monsters) { |
| 627 | 719 | if (m.hp <= 0) continue; |
| 628 | 720 | const def = monsterDef(content, m.kind); |
| @@ -638,6 +730,71 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo | ||
| 638 | 730 | } |
| 639 | 731 | } |
| 640 | 732 | |
| 733 | /** A hero's shot, flying `dir` (a unit vector) as far as the hero reaches. */ | |
| 734 | function loose( | |
| 735 | content: Content, | |
| 736 | world: World, | |
| 737 | hero: Hero, | |
| 738 | rules: HeroRules, | |
| 739 | dir: { x: number; z: number }, | |
| 740 | ): void { | |
| 741 | const attack = classOf(content, hero.cls).attack; | |
| 742 | const speed = attack.shotSpeed ?? 0.4; | |
| 743 | world.shots.push({ | |
| 744 | id: world.nextId++, | |
| 745 | x: hero.x + dir.x * 0.3, | |
| 746 | z: hero.z + dir.z * 0.3, | |
| 747 | dx: dir.x * speed, | |
| 748 | dz: dir.z * speed, | |
| 749 | ttl: Math.ceil(rules.reach / speed), | |
| 750 | damage: rules.damage + hero.power, | |
| 751 | by: hero.id, | |
| 752 | splash: attack.splash ?? 0, | |
| 753 | }); | |
| 754 | } | |
| 755 | ||
| 756 | /** The signature move: one per class, its numbers the content's. */ | |
| 757 | function cast(content: Content, world: World, hero: Hero, rules: HeroRules): void { | |
| 758 | const ability = classOf(content, hero.cls).ability; | |
| 759 | hero.ability = ability.cooldown; | |
| 760 | world.happened.push({ type: "ability", hero: hero.id, kind: ability.kind }); | |
| 761 | if (ability.kind === "taunt") { | |
| 762 | // Every monster in earshot wants the Knight now; the lid goes up. | |
| 763 | const ticks = ability.ticks ?? 60; | |
| 764 | for (const m of world.monsters) | |
| 765 | if (dist(m, hero) <= (ability.radius ?? 4)) { | |
| 766 | m.foe = hero.id; | |
| 767 | m.foeT = ticks; | |
| 768 | } | |
| 769 | hero.guard = ticks; | |
| 770 | } else if (ability.kind === "volley") { | |
| 771 | // A fan: the facing, nudged sideways a little more for each shot. | |
| 772 | const count = ability.count ?? 3; | |
| 773 | const spread = ability.spread ?? 0.2; | |
| 774 | for (let i = 0; i < count; i++) { | |
| 775 | const off = (i - (count - 1) / 2) * spread; | |
| 776 | const x = hero.fx - hero.fz * off; | |
| 777 | const z = hero.fz + hero.fx * off; | |
| 778 | const l = Math.sqrt(x * x + z * z) || 1; | |
| 779 | loose(content, world, hero, rules, { x: x / l, z: z / l }); | |
| 780 | } | |
| 781 | } else { | |
| 782 | // Soup for everyone close by; the downed sit up for it. | |
| 783 | const heal = ability.heal ?? 2; | |
| 784 | for (const h of world.heroes) { | |
| 785 | if (dist(h, hero) > (ability.radius ?? 3)) continue; | |
| 786 | const most = rulesOf(content, h.cls).hp; | |
| 787 | if (h.downed) { | |
| 788 | h.downed = false; | |
| 789 | h.revive = 0; | |
| 790 | h.hp = Math.min(most, heal); | |
| 791 | h.invuln = content.rules.hero.invuln * 2; | |
| 792 | world.happened.push({ type: "revived", hero: h.id }); | |
| 793 | } else h.hp = Math.min(most, h.hp + heal); | |
| 794 | } | |
| 795 | } | |
| 796 | } | |
| 797 | ||
| 641 | 798 | function roll(content: Content, world: World) { |
| 642 | 799 | const total = content.loot.reduce((sum, l) => sum + l.weight, 0); |
| 643 | 800 | let pick = random(world) * total; |
| @@ -650,6 +807,15 @@ function roll(content: Content, world: World) { | ||
| 650 | 807 | |
| 651 | 808 | function hurt(content: Content, world: World, hero: Hero, damage: number): boolean { |
| 652 | 809 | if (hero.downed || hero.invuln > 0 || hero.dodge > 0) return false; |
| 810 | if (hero.guard > 0) { | |
| 811 | const ability = classOf(content, hero.cls).ability; | |
| 812 | damage = Math.max(0, damage - (ability.block ?? 0)); | |
| 813 | if (damage === 0) { | |
| 814 | hero.invuln = content.rules.hero.invuln; | |
| 815 | world.happened.push({ type: "blocked", hero: hero.id }); | |
| 816 | return true; | |
| 817 | } | |
| 818 | } | |
| 653 | 819 | hero.hp -= damage; |
| 654 | 820 | hero.invuln = content.rules.hero.invuln; |
| 655 | 821 | world.happened.push({ type: "hurt", hero: hero.id, damage }); |
| @@ -665,6 +831,12 @@ function hurt(content: Content, world: World, hero: Hero, damage: number): boole | ||
| 665 | 831 | } |
| 666 | 832 | |
| 667 | 833 | function nearestHero(world: World, m: Monster): Hero | undefined { |
| 834 | if (m.foeT > 0) { | |
| 835 | m.foeT--; | |
| 836 | const foe = world.heroes.find((h) => h.id === m.foe && !h.downed); | |
| 837 | if (foe !== undefined) return foe; | |
| 838 | m.foeT = 0; | |
| 839 | } | |
| 668 | 840 | let best: Hero | undefined; |
| 669 | 841 | let bestD = Infinity; |
| 670 | 842 | for (const h of world.heroes) { |
| @@ -748,16 +920,34 @@ function stepMonster(content: Content, world: World, m: Monster): void { | ||
| 748 | 920 | dz: dir.z * speed, |
| 749 | 921 | ttl: Math.ceil((range + 3) / speed), |
| 750 | 922 | damage: def.damage, |
| 923 | by: 0, | |
| 924 | splash: 0, | |
| 751 | 925 | }); |
| 752 | 926 | m.cd = def.cooldown ?? 45; |
| 753 | 927 | } |
| 754 | 928 | } |
| 755 | 929 | |
| 756 | function stepShots(world: World): void { | |
| 930 | function stepShots(content: Content, world: World): void { | |
| 757 | 931 | world.shots = world.shots.filter((s) => { |
| 758 | 932 | s.x += s.dx; |
| 759 | 933 | s.z += s.dz; |
| 760 | 934 | s.ttl--; |
| 935 | if (s.by !== 0) { | |
| 936 | // A hero's shot lands on the first monster it reaches, splashing any near. | |
| 937 | const struck = world.monsters.find( | |
| 938 | (m) => m.hp > 0 && dist(s, m) < monsterDef(content, m.kind).radius + 0.15, | |
| 939 | ); | |
| 940 | if (struck !== undefined) { | |
| 941 | for (const m of world.monsters) { | |
| 942 | if (m.hp <= 0) continue; | |
| 943 | if (m !== struck && dist(s, m) > s.splash + monsterDef(content, m.kind).radius) continue; | |
| 944 | m.hp -= s.damage; | |
| 945 | m.hurt = 4; | |
| 946 | world.happened.push({ type: "hit", by: s.by, on: m.id, damage: s.damage }); | |
| 947 | } | |
| 948 | return false; | |
| 949 | } | |
| 950 | } | |
| 761 | 951 | return s.ttl > 0 && !solid(world, Math.floor(s.x), Math.floor(s.z)); |
| 762 | 952 | }); |
| 763 | 953 | } |
| @@ -767,7 +957,7 @@ function stepTraps(content: Content, world: World): void { | ||
| 767 | 957 | for (const hero of world.heroes) { |
| 768 | 958 | // A seed splats on whoever it reaches, unless they are rolling through it. |
| 769 | 959 | world.shots = world.shots.filter((s) => { |
| 770 | if (hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) return true; | |
| 960 | if (s.by !== 0 || hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) return true; | |
| 771 | 961 | hurt(content, world, hero, s.damage); |
| 772 | 962 | return false; |
| 773 | 963 | }); |
| @@ -804,6 +994,7 @@ export function snapshot(world: World) { | ||
| 804 | 994 | heroes: world.heroes.map((h) => ({ |
| 805 | 995 | id: h.id, |
| 806 | 996 | name: h.name, |
| 997 | cls: h.cls, | |
| 807 | 998 | x: round(h.x), |
| 808 | 999 | z: round(h.z), |
| 809 | 1000 | fx: round(h.fx), |
| @@ -815,6 +1006,8 @@ export function snapshot(world: World) { | ||
| 815 | 1006 | hit: h.invuln > 0 && h.dodge === 0, |
| 816 | 1007 | downed: h.downed, |
| 817 | 1008 | revive: h.revive, |
| 1009 | ability: h.ability, | |
| 1010 | guard: h.guard > 0, | |
| 818 | 1011 | })), |
| 819 | 1012 | monsters: world.monsters.map((m) => ({ |
| 820 | 1013 | id: m.id, |
| @@ -827,7 +1020,11 @@ export function snapshot(world: World) { | ||
| 827 | 1020 | fx: round(m.dx), |
| 828 | 1021 | fz: round(m.dz), |
| 829 | 1022 | })), |
| 830 | shots: world.shots.map((s) => ({ id: s.id, x: round(s.x), z: round(s.z) })), | |
| 1023 | shots: world.shots.map((s) => | |
| 1024 | s.by === 0 | |
| 1025 | ? { id: s.id, x: round(s.x), z: round(s.z) } | |
| 1026 | : { id: s.id, x: round(s.x), z: round(s.z), by: s.by, fx: round(s.dx), fz: round(s.dz) }, | |
| 1027 | ), | |
| 831 | 1028 | chests: world.chests.map((c) => ({ id: c.id, x: c.x, z: c.z, open: c.open })), |
| 832 | 1029 | }; |
| 833 | 1030 | } |
static/app.css +31
| @@ -384,6 +384,37 @@ kbd { | ||
| 384 | 384 | .pad .button { |
| 385 | 385 | justify-content: center; |
| 386 | 386 | } |
| 387 | .pad-three { | |
| 388 | grid-template-columns: 1fr 1fr 1fr; | |
| 389 | } | |
| 390 | ||
| 391 | /* The dressing room: one card a class, side by side when there is room. */ | |
| 392 | .class-list { | |
| 393 | list-style: none; | |
| 394 | margin: 0; | |
| 395 | padding: 0; | |
| 396 | display: grid; | |
| 397 | grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); | |
| 398 | gap: var(); | |
| 399 | } | |
| 400 | .class-card { | |
| 401 | display: grid; | |
| 402 | gap: var(); | |
| 403 | align-content: start; | |
| 404 | padding: var(); | |
| 405 | border: 1px solid var(); | |
| 406 | border-radius: var(); | |
| 407 | } | |
| 408 | .class-card p, | |
| 409 | .class-facts { | |
| 410 | margin: 0; | |
| 411 | } | |
| 412 | .class-name { | |
| 413 | font-size: var(); | |
| 414 | } | |
| 415 | .class-facts li + li { | |
| 416 | margin-top: var(); | |
| 417 | } | |
| 387 | 418 | |
| 388 | 419 | /* A word from the last request, shown once, gone in a moment. */ |
| 389 | 420 | .toast { |
static/delve.js +34 −6
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | // second) and sends every other tick down /delves/:id/events; this file |
| 3 | 3 | // draws between the last two it heard, and sends orders back: click or tap |
| 4 | 4 | // the floor to walk, a monster to fight it, shift-click or right-click to |
| 5 | // dodge; WASD/arrows, Space to attack, K to dodge. The same sim.ts, served | |
| 6 | // as /sim.js, lays out the rooms here. | |
| 5 | // dodge; WASD/arrows, Space to attack, K to dodge, Q for the class's | |
| 6 | // signature move. The same sim.ts, served as /sim.js, lays out the rooms | |
| 7 | // here and says what each class is. | |
| 7 | 8 | |
| 8 | 9 | import { compose, createRenderer, drawChef, paint } from "./gl.js"; |
| 9 | 10 | |
| @@ -85,6 +86,7 @@ function blend(list, before, f) { | ||
| 85 | 86 | }); |
| 86 | 87 | } |
| 87 | 88 | const mine = () => cur?.heroes.find((h) => h.id === heroId); |
| 89 | const classOf = (id) => sim.classOf(game, id); | |
| 88 | 90 | |
| 89 | 91 | function bakeRoom() { |
| 90 | 92 | const def = game.rooms.find((r) => r.id === cur.room); |
| @@ -144,7 +146,15 @@ function frame(now) { | ||
| 144 | 146 | } |
| 145 | 147 | |
| 146 | 148 | for (const m of monsters) drawMonster(m, t); |
| 147 | for (const s of shots) renderer.draw("gem", compose(s.x, 0.35, s.z, t / 3, 0.18, 0.18, 0.18), paint("game-seed")); | |
| 149 | for (const s of shots) { | |
| 150 | const by = s.by ? cur.heroes.find((h) => h.id === s.by) : null; | |
| 151 | const look = by ? classOf(by.cls).look : null; | |
| 152 | if (look === "bow") | |
| 153 | renderer.draw("box", compose(s.x, 0.45, s.z, Math.atan2(s.fx, s.fz), 0.06, 0.06, 0.55), paint("game-baguette")); | |
| 154 | else if (by) | |
| 155 | renderer.draw("gem", compose(s.x, 0.45, s.z, t / 2, 0.3, 0.3, 0.3), paint("game-gold")); | |
| 156 | else renderer.draw("gem", compose(s.x, 0.35, s.z, t / 3, 0.18, 0.18, 0.18), paint("game-seed")); | |
| 157 | } | |
| 148 | 158 | |
| 149 | 159 | if (ghost) { |
| 150 | 160 | const g = ghostAt(t); |
| @@ -159,12 +169,18 @@ function frame(now) { | ||
| 159 | 169 | if (h.id === heroId) |
| 160 | 170 | renderer.draw("prism", compose(h.x, 0.01, h.z, 0, 0.8, 0.02, 0.8), paint("game-apron"), 0.5); |
| 161 | 171 | const blink = h.hit && !reduced && Math.floor(now / 80) % 2 === 0; |
| 172 | const cls = classOf(h.cls); | |
| 162 | 173 | drawChef(renderer, h.x, h.z, face, { |
| 174 | look: cls.look, | |
| 175 | apron: cls.paint, | |
| 163 | 176 | swing: h.swing, |
| 164 | 177 | lying: h.downed, |
| 165 | 178 | lift: h.dodge ? 0.15 : 0, |
| 166 | 179 | tint: blink ? paint("game-pepper") : undefined, |
| 167 | 180 | }); |
| 181 | // The Knight's lid is up: a glint over the hat. | |
| 182 | if (h.guard && !h.downed) | |
| 183 | renderer.draw("gem", compose(h.x, 1.45, h.z, t / 4, 0.22, 0.22, 0.22), paint("game-gold")); | |
| 168 | 184 | const at = renderer.project(h.x, 1.5, h.z); |
| 169 | 185 | if (at) labels.push({ id: h.id, at, h }); |
| 170 | 186 | } |
| @@ -206,7 +222,7 @@ function drawTags(labels) { | ||
| 206 | 222 | tagEls.set(id, el); |
| 207 | 223 | } |
| 208 | 224 | el.classList.toggle("tag-me", id === heroId); |
| 209 | el.firstChild.textContent = h.name; | |
| 225 | el.firstChild.textContent = `${h.name} · ${classOf(h.cls).name}`; | |
| 210 | 226 | const note = h.downed ? `down · ${Math.round((h.revive / game.rules.hero.revive) * 100)}%` : ""; |
| 211 | 227 | el.lastChild.textContent = note; |
| 212 | 228 | el.lastChild.hidden = note === ""; |
| @@ -260,10 +276,15 @@ function showHud() { | ||
| 260 | 276 | const me = mine(); |
| 261 | 277 | hud("score").textContent = `${cur.score} pts · ${cur.gold} gold · ${cur.kills} down`; |
| 262 | 278 | hud("hp").textContent = me |
| 263 | ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, game.rules.hero.hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}` | |
| 279 | ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, sim.rulesOf(game, me.cls).hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}` | |
| 264 | 280 | : "Watching"; |
| 281 | if (me) { | |
| 282 | const cls = classOf(me.cls); | |
| 283 | const wait = Math.ceil(me.ability / game.rules.tickHz); | |
| 284 | hud("class").textContent = `${cls.name} · ${cls.ability.name} ${wait > 0 ? `in ${wait}s` : "ready (Q)"}`; | |
| 285 | } | |
| 265 | 286 | hud("party").textContent = cur.heroes |
| 266 | .map((h) => `${h.name} ${h.downed ? "(down)" : `${h.hp}♥`}`) | |
| 287 | .map((h) => `${h.name} (${classOf(h.cls).name}) ${h.downed ? "(down)" : `${h.hp}♥`}`) | |
| 267 | 288 | .join(" · "); |
| 268 | 289 | if (me?.downed && !cur.over) say("You're down! A teammate standing beside you brings you back."); |
| 269 | 290 | const over = document.getElementById("over"); |
| @@ -301,6 +322,9 @@ function attackNearest() { | ||
| 301 | 322 | const m = nearestMonster(me.x, me.z, 4); |
| 302 | 323 | orderUp(m ? { type: "attack", target: m.id } : { type: "attack" }); |
| 303 | 324 | } |
| 325 | function signature() { | |
| 326 | if (mine()) orderUp({ type: "ability" }); | |
| 327 | } | |
| 304 | 328 | function dodgeAhead() { |
| 305 | 329 | const me = mine(); |
| 306 | 330 | if (!me) return; |
| @@ -343,6 +367,9 @@ function listen() { | ||
| 343 | 367 | } else if (k === "k" || k === "Shift") { |
| 344 | 368 | e.preventDefault(); |
| 345 | 369 | dodgeAhead(); |
| 370 | } else if (k === "q" || k === "l") { | |
| 371 | e.preventDefault(); | |
| 372 | signature(); | |
| 346 | 373 | } |
| 347 | 374 | }); |
| 348 | 375 | document.addEventListener("keyup", (e) => { |
| @@ -351,6 +378,7 @@ function listen() { | ||
| 351 | 378 | }); |
| 352 | 379 | document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest); |
| 353 | 380 | document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead); |
| 381 | document.querySelector("[data-act=ability]")?.addEventListener("click", signature); | |
| 354 | 382 | } |
| 355 | 383 | |
| 356 | 384 | // Held keys walk a couple of tiles ahead, again while they stay held. |
static/gl.js +30 −8
| @@ -306,11 +306,15 @@ export function createRenderer(canvas) { | ||
| 306 | 306 | }; |
| 307 | 307 | } |
| 308 | 308 | |
| 309 | /** A chef, in five shapes: whites, apron, hat and puff, and a knife when it swings. */ | |
| 309 | /** | |
| 310 | * A chef, in a handful of shapes: whites, an apron in their class's colour, | |
| 311 | * hat and puff, and their class's weapon (`look`) — a cleaver and pot lid, | |
| 312 | * a bow, or a staff — sweeping or glowing while it swings. | |
| 313 | */ | |
| 310 | 314 | export function drawChef(r, x, z, facing, opts = {}) { |
| 311 | const { alpha = 1, swing = 0, lying = false, lift = 0, tint, spin = 0, wave = 0 } = opts; | |
| 315 | const { alpha = 1, swing = 0, lying = false, lift = 0, tint, spin = 0, wave = 0, look = "sword" } = opts; | |
| 312 | 316 | const whites = tint ?? paint("game-hero"); |
| 313 | const apron = tint ?? paint("game-apron"); | |
| 317 | const apron = tint ?? paint(opts.apron ?? "game-apron"); | |
| 314 | 318 | const ry = facing + spin; |
| 315 | 319 | const tilt = lying ? Math.PI / 2 : 0; |
| 316 | 320 | const y = lift; |
| @@ -321,11 +325,29 @@ export function drawChef(r, x, z, facing, opts = {}) { | ||
| 321 | 325 | r.draw("box", compose(x, y + 0.62, z, ry, 0.4, 0.28, 0.32), paint("game-seed"), alpha); |
| 322 | 326 | r.draw("prism", compose(x, y + 0.9, z, ry, 0.34, 0.26, 0.34), whites, alpha); |
| 323 | 327 | r.draw("gem", compose(x, y + 1.08, z, ry, 0.5, 0.3, 0.5), whites, alpha); |
| 324 | // The knife: resting at the side, sweeping across while it swings. | |
| 325 | const arc = swing > 0 ? (swing / 6) * 2.4 - 1.2 : 0.9; | |
| 326 | const side = ry + arc; | |
| 327 | const kx = x + Math.sin(side) * 0.42, kz = z + Math.cos(side) * 0.42; | |
| 328 | r.draw("box", compose(kx, y + 0.34 + wave, kz, side, 0.07, 0.07, swing > 0 ? 0.6 : 0.4), paint("game-spike"), alpha); | |
| 328 | const rx = Math.cos(ry), rz = -Math.sin(ry); // the chef's right hand | |
| 329 | if (look === "bow") { | |
| 330 | // The bow: held out in front, drawn back while it looses. | |
| 331 | const out = swing > 0 ? 0.42 : 0.3; | |
| 332 | const bx = x + fx * out - rx * 0.12, bz = z + fz * out - rz * 0.12; | |
| 333 | r.draw("box", compose(bx, y + 0.12 + wave, bz, ry, 0.08, 0.72, 0.06), paint("game-baguette"), alpha); | |
| 334 | r.draw("box", compose(bx - fx * (swing > 0 ? 0.14 : 0.05), y + 0.14 + wave, bz - fz * (swing > 0 ? 0.14 : 0.05), ry, 0.02, 0.66, 0.02), whites, alpha); | |
| 335 | } else if (look === "staff") { | |
| 336 | // The ladle-staff: upright at the side, its gem flaring while it casts. | |
| 337 | const sx = x + rx * 0.34, sz = z + rz * 0.34; | |
| 338 | r.draw("box", compose(sx, y + wave, sz, ry, 0.07, 1.15, 0.07, swing > 0 ? 0.4 : 0), paint("game-baguette"), alpha); | |
| 339 | const g = swing > 0 ? 0.3 : 0.2; | |
| 340 | r.draw("gem", compose(sx + fx * (swing > 0 ? 0.45 : 0), y + 1.15 + wave, sz + fz * (swing > 0 ? 0.45 : 0), ry, g, g, g), paint("game-gold"), alpha); | |
| 341 | } else { | |
| 342 | // The cleaver: resting at the side, sweeping across while it swings; | |
| 343 | // the pot lid on the other arm. | |
| 344 | const arc = swing > 0 ? (swing / 6) * 2.4 - 1.2 : 0.9; | |
| 345 | const side = ry + arc; | |
| 346 | const kx = x + Math.sin(side) * 0.42, kz = z + Math.cos(side) * 0.42; | |
| 347 | r.draw("box", compose(kx, y + 0.34 + wave, kz, side, 0.07, 0.07, swing > 0 ? 0.6 : 0.4), paint("game-spike"), alpha); | |
| 348 | if (look === "sword") | |
| 349 | r.draw("box", compose(x - rx * 0.32 + fx * 0.08, y + 0.18, z - rz * 0.32 + fz * 0.08, ry, 0.06, 0.4, 0.4), paint("game-spike"), alpha); | |
| 350 | } | |
| 329 | 351 | } else { |
| 330 | 352 | r.draw("prism", compose(x, 0.1, z + 0.5, ry, 0.34, 0.3, 0.34, tilt), whites, alpha); |
| 331 | 353 | } |
static/hub.js +20 −1
| @@ -121,7 +121,10 @@ function frame(now) { | ||
| 121 | 121 | const since = (now - a.emoteAt) / 1000; |
| 122 | 122 | const emote = a.view.emote && since < 3 && !reduced ? a.view.emote : null; |
| 123 | 123 | const bob = p.moving && !reduced ? Math.abs(Math.sin(now / 90)) * 0.08 : 0; |
| 124 | const cls = classOf(a.view.class); | |
| 124 | 125 | drawChef(renderer, p.x, p.z, face, { |
| 126 | look: cls?.look, | |
| 127 | apron: cls?.paint, | |
| 125 | 128 | lift: bob + (emote === "cheer" ? Math.abs(Math.sin(since * 9)) * 0.5 : 0), |
| 126 | 129 | spin: emote === "dance" ? since * 8 : 0, |
| 127 | 130 | wave: emote === "wave" ? Math.sin(since * 14) * 0.25 + 0.3 : 0, |
| @@ -135,6 +138,8 @@ function frame(now) { | ||
| 135 | 138 | drawTags(labels, now); |
| 136 | 139 | } |
| 137 | 140 | |
| 141 | const classOf = (id) => game?.classes.find((c) => c.id === id); | |
| 142 | ||
| 138 | 143 | // Name tags and speech bubbles are HTML over the canvas: real text, set |
| 139 | 144 | // with textContent, never markup. |
| 140 | 145 | const tagEls = new Map(); |
| @@ -151,7 +156,8 @@ function drawTags(labels, now) { | ||
| 151 | 156 | tagEls.set(id, el); |
| 152 | 157 | } |
| 153 | 158 | el.classList.toggle("tag-me", mine); |
| 154 | el.firstChild.textContent = a.view.name; | |
| 159 | const cls = classOf(a.view.class); | |
| 160 | el.firstChild.textContent = cls ? `${a.view.name} · ${cls.name}` : a.view.name; | |
| 155 | 161 | const talking = a.view.say && now - a.sayAt < 7000; |
| 156 | 162 | el.lastChild.textContent = talking ? a.view.say : ""; |
| 157 | 163 | el.lastChild.hidden = !talking; |
| @@ -197,10 +203,23 @@ function listen() { | ||
| 197 | 203 | learn(view); |
| 198 | 204 | form.reset(); |
| 199 | 205 | say(""); |
| 206 | if (form.dataset.hub === "dress") wearing(view.class); | |
| 200 | 207 | } |
| 201 | 208 | }); |
| 202 | 209 | } |
| 203 | 210 | |
| 211 | // The dressing room shows what you have on. | |
| 212 | function wearing(id) { | |
| 213 | for (const b of document.querySelectorAll("form[data-hub=dress] button")) { | |
| 214 | const on = b.value === id; | |
| 215 | b.setAttribute("aria-pressed", String(on)); | |
| 216 | b.classList.toggle("button-quiet", !on); | |
| 217 | b.textContent = on ? "Wearing it" : `Wear the ${b.dataset.name}`; | |
| 218 | } | |
| 219 | const cls = classOf(id); | |
| 220 | if (cls) say(`Dressed as the ${cls.name}. Your next delve, you're cooking with the ${cls.weapon}.`); | |
| 221 | } | |
| 222 | ||
| 204 | 223 | async function start() { |
| 205 | 224 | if (!renderer) return; |
| 206 | 225 | root.classList.add("ready"); |