@nano133/nanoterra 0.1.0 → 0.3.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 (106) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +269 -12
  3. package/ai/SPEC.md +7 -2
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +24 -0
  33. package/dist/admin/panel/shelf.js +271 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/character/bases/bee.js +7 -5
  45. package/dist/character/bases/spec.d.ts +7 -0
  46. package/dist/character/create.d.ts +15 -0
  47. package/dist/character/create.js +69 -2
  48. package/dist/character/ctx.d.ts +12 -2
  49. package/dist/character/index.d.ts +2 -0
  50. package/dist/character/index.js +2 -0
  51. package/dist/character/make.js +2 -1
  52. package/dist/character/preview.d.ts +32 -0
  53. package/dist/character/preview.js +112 -0
  54. package/dist/character/sockets.d.ts +37 -0
  55. package/dist/character/sockets.js +66 -0
  56. package/dist/looks/change.d.ts +8 -4
  57. package/dist/looks/change.js +25 -3
  58. package/dist/looks/index.d.ts +3 -3
  59. package/dist/looks/index.js +2 -2
  60. package/dist/looks/json.d.ts +2 -2
  61. package/dist/looks/json.js +6 -4
  62. package/dist/looks/look.d.ts +29 -3
  63. package/dist/looks/look.js +60 -4
  64. package/dist/model/check.d.ts +59 -0
  65. package/dist/model/check.js +681 -0
  66. package/dist/model/index.d.ts +2 -0
  67. package/dist/model/index.js +5 -0
  68. package/dist/model/limits.d.ts +103 -0
  69. package/dist/model/limits.js +132 -0
  70. package/package.json +11 -3
  71. package/src/admin/adapters/d1.ts +94 -0
  72. package/src/admin/adapters/firestore.ts +78 -0
  73. package/src/admin/amount.ts +14 -0
  74. package/src/admin/conformance.ts +605 -0
  75. package/src/admin/guard.ts +86 -0
  76. package/src/admin/handler.ts +200 -0
  77. package/src/admin/http.ts +74 -0
  78. package/src/admin/index.ts +21 -0
  79. package/src/admin/memory.ts +59 -0
  80. package/src/admin/panel/api.ts +37 -0
  81. package/src/admin/panel/dom.ts +31 -0
  82. package/src/admin/panel/gameCard.ts +63 -0
  83. package/src/admin/panel/index.ts +18 -0
  84. package/src/admin/panel/mount.ts +227 -0
  85. package/src/admin/panel/shelf.ts +361 -0
  86. package/src/admin/panel/shelfData.ts +87 -0
  87. package/src/admin/panel/signins.ts +68 -0
  88. package/src/admin/panel/style.css +551 -0
  89. package/src/admin/store.ts +98 -0
  90. package/src/admin/summary.ts +103 -0
  91. package/src/admin/switches.ts +74 -0
  92. package/src/character/bases/bee.ts +7 -5
  93. package/src/character/bases/spec.ts +4 -0
  94. package/src/character/create.ts +73 -2
  95. package/src/character/ctx.ts +17 -2
  96. package/src/character/index.ts +2 -0
  97. package/src/character/make.ts +2 -1
  98. package/src/character/preview.ts +148 -0
  99. package/src/character/sockets.ts +81 -0
  100. package/src/looks/change.ts +25 -6
  101. package/src/looks/index.ts +3 -3
  102. package/src/looks/json.ts +6 -4
  103. package/src/looks/look.ts +66 -5
  104. package/src/model/check.ts +624 -0
  105. package/src/model/index.ts +5 -0
  106. package/src/model/limits.ts +193 -0
@@ -0,0 +1,98 @@
1
+ // What a game gives the admin handler: a store (an adapter for its database) and a status. The package has no database of its own.
2
+
3
+ import type { GamePatch } from "./switches.js";
4
+
5
+ /** Money as the admin page shows it: a count, the XNO text and the US dollars as text. */
6
+ export type Money = { count: number; xno: string; usd: string };
7
+
8
+ export type OrderStatus = "open" | "paid" | "expired" | "paid_late";
9
+
10
+ export type AdminOrder = {
11
+ id: string;
12
+ /** The player's Nano address. */
13
+ player: string;
14
+ lookId: string;
15
+ /** What the player reads for the look (the handler fills it from `lookName`). */
16
+ look: string;
17
+ usd: string;
18
+ xno: string;
19
+ status: OrderStatus | string;
20
+ createdAt: number;
21
+ expiresAt: number;
22
+ paidAt: number | null;
23
+ payer: string | null;
24
+ hash: string | null;
25
+ };
26
+
27
+ export type AdminPlayer = { player: string; looks: { id: string; name: string; at: number }[] };
28
+
29
+ /** The numbers of the orders and the looks that players own. */
30
+ export type StoreSummary = {
31
+ counts: { open: number; paid: number; expired: number; paid_late: number };
32
+ totals: { paid: Money; paidLate: Money };
33
+ /** Newest first, at most 500. `look` is filled by the handler. */
34
+ orders: Omit<AdminOrder, "look">[];
35
+ /** Players and the looks that a paid order gave them. `name` is filled by the handler. */
36
+ players: { player: string; looks: { id: string; at: number }[] }[];
37
+ /** The price of XNO in US dollars that the game uses, and where it came from. */
38
+ rate: { usd: number; sources: string[]; at: number } | null;
39
+ };
40
+
41
+ /**
42
+ * One admin sign-in, as the panel lists it: when, the rough country (two letters, or null when the game did not know it) and a short browser name ("Chrome on macOS").
43
+ * Nothing else: no address, no IP, no browser text. The game writes these when it makes an admin login; the package only reads them.
44
+ */
45
+ export type AdminLogin = { at: number; country: string | null; browser: string };
46
+
47
+ /** How many sign-ins the panel lists. */
48
+ export const ADMIN_LOGINS_SHOWN = 10;
49
+
50
+ /**
51
+ * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the two settings and, when the game turns it on, the deletion of the admin sessions
52
+ * (`signOutEverywhere`). Nothing else.
53
+ * (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
54
+ */
55
+ export interface AdminStore {
56
+ /** The settings that /admin has saved. A value that is not a known word is ignored by the handler, so an adapter returns what it holds. */
57
+ readSettings(): Promise<{ character?: unknown; signin?: unknown }>;
58
+ /** Saves a checked patch: all of it or none of it. */
59
+ saveSettings(patch: GamePatch, now: number): Promise<void>;
60
+ summary(now: number): Promise<StoreSummary>;
61
+ /**
62
+ * Optional. The last sign-ins to the admin, newest first, at most `limit`, with only the three things of `AdminLogin`. With it the summary lists them (the "Sign-ins" card).
63
+ * An adapter that has it must never return more than those three things for a sign-in.
64
+ */
65
+ recentLogins?(limit: number): Promise<AdminLogin[]>;
66
+ /**
67
+ * Optional. Deletes EVERY admin session (the admin login of the owner, in every browser, this one too) and no other session; returns how many it deleted. With it the handler has
68
+ * the route `POST {base}/signout-all` and the panel has "Sign out everywhere". An adapter must delete only sessions of the ADMIN scope.
69
+ */
70
+ signOutEverywhere?(): Promise<number>;
71
+ }
72
+
73
+ /** What the game says about itself on the Status card: only the game knows its address, its test mode and what is still off. */
74
+ export type AdminStatus = {
75
+ address: string | null;
76
+ testMode: boolean;
77
+ /** Whether Turnstile (the bot check) is on. */
78
+ turnstile?: boolean;
79
+ /** The names of the Nano nodes that check payments. */
80
+ nodes?: string[];
81
+ /** "owner/repo" on GitHub, for the link to looks.json. */
82
+ repo?: string | null;
83
+ /** Plain sentences about what is still off. */
84
+ warnings: string[];
85
+ };
86
+
87
+ /** The whole of GET summary. */
88
+ export type AdminSummary = AdminStatus &
89
+ Omit<StoreSummary, "orders" | "players"> & {
90
+ orders: AdminOrder[];
91
+ players: AdminPlayer[];
92
+ /** Whether the game gave a payment check (POST check). The panel shows "Check payments now" only then. */
93
+ canCheck: boolean;
94
+ /** The last admin sign-ins (newest first), when the store can list them. */
95
+ logins?: AdminLogin[];
96
+ /** Whether the route "sign out everywhere" exists (the store can do it). */
97
+ canSignOutEverywhere?: boolean;
98
+ };
@@ -0,0 +1,103 @@
1
+ // The numbers of the summary from the rows of the orders, the same for every adapter: the counts by status, the totals and the shape of an order.
2
+ // An adapter reads its rows; this file turns them into a `StoreSummary`.
3
+
4
+ import { isRawText, xnoText } from "./amount.js";
5
+ import type { AdminOrder, Money, StoreSummary } from "./store.js";
6
+
7
+ /** An order as an adapter reads it: the amount is the raw amount (digits), the times are ms. */
8
+ export type OrderRow = {
9
+ id: string;
10
+ player: string;
11
+ lookId: string;
12
+ usd: string;
13
+ amountRaw: string;
14
+ status: string;
15
+ createdAt: number;
16
+ expiresAt: number;
17
+ paidAt: number | null;
18
+ payer: string | null;
19
+ hash: string | null;
20
+ };
21
+
22
+ export const ORDERS_SHOWN = 500;
23
+
24
+ /** Cents from a dollar text like "0.25". Anything that is not plain dollars (a sign, an exponent, hex) counts as 0: a bad row must not change a total. */
25
+ const toCents = (usd: string): number => {
26
+ if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd)) return 0;
27
+ return Math.round(Number(usd) * 100);
28
+ };
29
+
30
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
31
+ export function moneyOf(rows: readonly { amountRaw: string; usd: string }[]): Money {
32
+ let raw = 0n;
33
+ let cents = 0;
34
+ for (const r of rows) {
35
+ if (isRawText(r.amountRaw)) raw += BigInt(r.amountRaw);
36
+ cents += toCents(r.usd);
37
+ }
38
+ return { count: rows.length, xno: xnoText(raw.toString()), usd: (cents / 100).toFixed(2) };
39
+ }
40
+
41
+ /** Where an order counts: an open order past its expiry counts as expired. */
42
+ export const statusOf = (status: string, expiresAt: number, now: number): string => (status === "open" && expiresAt < now ? "expired" : status);
43
+
44
+ /** The orders as the page lists them: newest first, at most ORDERS_SHOWN, an open one past its expiry read as "expired". */
45
+ export function shapeOrders(rows: readonly OrderRow[], now: number): Omit<AdminOrder, "look">[] {
46
+ return [...rows]
47
+ .sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : 1))
48
+ .slice(0, ORDERS_SHOWN)
49
+ .map((o) => ({
50
+ id: o.id,
51
+ player: o.player,
52
+ lookId: o.lookId,
53
+ usd: o.usd,
54
+ xno: isRawText(o.amountRaw) ? xnoText(o.amountRaw) : "0",
55
+ status: statusOf(o.status, o.expiresAt, now),
56
+ createdAt: o.createdAt,
57
+ expiresAt: o.expiresAt,
58
+ paidAt: o.paidAt,
59
+ payer: o.payer,
60
+ hash: o.hash,
61
+ }));
62
+ }
63
+
64
+ /** The looks that players own, by player (players and their looks in a steady order). */
65
+ export function shapePlayers(owned: readonly { player: string; lookId: string; at: number }[]): StoreSummary["players"] {
66
+ const players = new Map<string, { player: string; looks: { id: string; at: number }[] }>();
67
+ for (const o of [...owned].sort((a, b) => (a.player < b.player ? -1 : a.player > b.player ? 1 : a.at - b.at))) {
68
+ const p = players.get(o.player) ?? { player: o.player, looks: [] };
69
+ p.looks.push({ id: o.lookId, at: o.at });
70
+ players.set(o.player, p);
71
+ }
72
+ return [...players.values()];
73
+ }
74
+
75
+ /** The whole summary from ALL the rows (for a store that reads the rows itself: Firestore, memory). */
76
+ export function summarize(rows: readonly OrderRow[], owned: readonly { player: string; lookId: string; at: number }[], rate: StoreSummary["rate"], now: number): StoreSummary {
77
+ const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
78
+ for (const o of rows) {
79
+ const key = statusOf(o.status, o.expiresAt, now);
80
+ if (Object.hasOwn(counts, key)) counts[key as keyof typeof counts]++;
81
+ }
82
+ return {
83
+ counts,
84
+ totals: { paid: moneyOf(rows.filter((o) => o.status === "paid")), paidLate: moneyOf(rows.filter((o) => o.status === "paid_late")) },
85
+ orders: shapeOrders(rows, now),
86
+ players: shapePlayers(owned),
87
+ rate,
88
+ };
89
+ }
90
+
91
+ /** The `rate` row of the meta table: JSON { usd, sources } kept with the time. Anything that is not that is no rate. */
92
+ export function parseRate(value: unknown, at: unknown): StoreSummary["rate"] {
93
+ if (typeof value !== "string" || typeof at !== "number") return null;
94
+ try {
95
+ const v: unknown = JSON.parse(value);
96
+ if (!v || typeof v !== "object") return null;
97
+ const { usd, sources } = v as { usd?: unknown; sources?: unknown };
98
+ if (typeof usd !== "number" || !Number.isFinite(usd) || !Array.isArray(sources) || !sources.every((s) => typeof s === "string")) return null;
99
+ return { usd, sources: sources as string[], at };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
@@ -0,0 +1,74 @@
1
+ // The two switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
+ // draws the card from `SWITCHES`. A switch changes ONLY these two settings; never a look, a price or an order.
3
+
4
+ /** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
5
+ export type Character = "nanoterra" | "own";
6
+ /** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
7
+ export type Signin = "exchange" | "ticket" | "none";
8
+ export type GameSettings = { character: Character; signin: Signin };
9
+ /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
10
+ export type GamePatch = { character?: Character; signin?: "exchange" | "none" };
11
+ /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
12
+ export type GameState = GameSettings & { fromAdmin: { character: boolean; signin: boolean } };
13
+
14
+ export const isCharacter = (v: unknown): v is Character => v === "nanoterra" || v === "own";
15
+ export const isSigninChoice = (v: unknown): v is "exchange" | "none" => v === "exchange" || v === "none";
16
+ export const isSignin = (v: unknown): v is Signin => v === "exchange" || v === "ticket" || v === "none";
17
+ /** The sign-in switch is on for any way but "none". */
18
+ export const signinOn = (s: Signin): boolean => s !== "none";
19
+
20
+ /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
21
+ export function parsePatch(body: unknown): { ok: true; patch: GamePatch } | { ok: false; error: string } {
22
+ if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character" or "signin".' };
23
+ const patch: GamePatch = {};
24
+ for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
25
+ if (key === "character" && isCharacter(value)) patch.character = value;
26
+ else if (key === "signin" && isSigninChoice(value)) patch.signin = value;
27
+ else if (key === "character") return { ok: false, error: '"character" must be "nanoterra" or "own".' };
28
+ else if (key === "signin") return { ok: false, error: '"signin" must be "exchange" or "none".' };
29
+ else return { ok: false, error: `"${key}" is not a game switch.` };
30
+ }
31
+ if (!Object.keys(patch).length) return { ok: false, error: 'Send "character" or "signin".' };
32
+ return { ok: true, patch };
33
+ }
34
+
35
+ /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
36
+ export function mergeSettings(stored: { character?: unknown; signin?: unknown }, defaults: GameSettings): GameState {
37
+ return {
38
+ character: isCharacter(stored.character) ? stored.character : defaults.character,
39
+ signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
40
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin) },
41
+ };
42
+ }
43
+
44
+ /** One switch of the card: its title, and one plain sentence for each state. */
45
+ export type SwitchId = "character" | "signin";
46
+ export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string }> = {
47
+ character: {
48
+ title: "Players use their Nanoterra character",
49
+ on: "On: a player who signs in from Nanoterra plays as the character they picked there. Anyone else plays as the island's first character.",
50
+ off: "Off: every player plays as this game's own character.",
51
+ },
52
+ signin: {
53
+ title: "Players can sign in with Nanoterra",
54
+ on: "On: this game's listing file says players can sign in with Nanoterra, and the game accepts that sign-in.",
55
+ off: "Off: the listing file says no sign-in, and the game refuses it: a link made before still signs nobody in.",
56
+ },
57
+ };
58
+
59
+ /**
60
+ * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
61
+ * card says what a player gets: the island's first character, every player, a player who signed in from Nanoterra before included ("Off means Off"). That is the GAME's page
62
+ * to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
63
+ */
64
+ export const CHARACTER_NEEDS_SIGNIN = "This needs “Players can sign in with Nanoterra” to be On: only a player who signs in from Nanoterra brings a character.";
65
+ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before included.";
66
+
67
+ /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
68
+ export const REVIEW_NOTE =
69
+ "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
70
+ export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
71
+
72
+ /** The state of a switch from the settings, and the patch that a press sends (the other state). */
73
+ export const stateOf = (id: SwitchId, s: GameSettings): boolean => (id === "character" ? s.character === "nanoterra" : signinOn(s.signin));
74
+ export const pressOf = (id: SwitchId, s: GameSettings): GamePatch => (id === "character" ? { character: s.character === "nanoterra" ? "own" : "nanoterra" } : { signin: signinOn(s.signin) ? "none" : "exchange" });
@@ -30,11 +30,13 @@ export const bee: BaseSpec = {
30
30
  addFace(c);
31
31
  if (!c.headOnly) {
32
32
  c.addBody(G.cone8, mtx(0, 0.72, -0.5, -Math.PI / 2 + 0.25, 0, 0, 0.1, 0.32, 0.1), "stripe", "hips");
33
- // The wings: a bone at each shoulder blade; a long wing and a short one on it.
34
- for (const [s, name] of [[1, "wing_L"], [-1, "wing_R"]] as const) {
35
- c.b.bone(name, "chest", [s * 0.12, 1.4, -0.3]);
36
- c.add(SPH_S, mtx(s * 0.3, 1.78, -0.4, 0.2, 0, s * 1.15, 0.4, 0.04, 0.22), "wing", name);
37
- c.add(SPH_S, mtx(s * 0.36, 1.52, -0.42, 0.15, 0, s * 0.8, 0.28, 0.035, 0.15), "wing", name);
33
+ // The wings: a bone at each shoulder blade; a long wing and a short one on it. A back item covers them (`hideWings`): no wing, no bone.
34
+ if (!c.hideWings) {
35
+ for (const [s, name] of [[1, "wing_L"], [-1, "wing_R"]] as const) {
36
+ c.b.bone(name, "chest", [s * 0.12, 1.4, -0.3]);
37
+ c.add(SPH_S, mtx(s * 0.3, 1.78, -0.4, 0.2, 0, s * 1.15, 0.4, 0.04, 0.22), "wing", name);
38
+ c.add(SPH_S, mtx(s * 0.36, 1.52, -0.42, 0.15, 0, s * 0.8, 0.28, 0.035, 0.15), "wing", name);
39
+ }
38
40
  }
39
41
  }
40
42
  limbs(c, bee.limbs);
@@ -14,5 +14,9 @@ export type BaseSpec = {
14
14
  limbs: Limbs;
15
15
  /** Where a hat sits: the hat frame's centre height (a Classic hat is built for 1.83) and its scale. */
16
16
  hat: { y: number; z?: number; s: readonly [number, number, number] };
17
+ /** The hand socket's place from the middle of the drawn right hand, when it is not that middle (sockets.ts). */
18
+ handAt?: readonly [number, number, number];
19
+ /** The back socket: its height (left out: the chest bone's, 1.12) and its depth (left out: the torso's radius there, behind). */
20
+ back?: { y: number; z?: number };
17
21
  build(c: Ctx): void;
18
22
  };
@@ -7,11 +7,14 @@ import { buildWith, paintCharacter, type Made } from "./make.js";
7
7
  import { makePose, MAX_DT, type Motion } from "./pose.js";
8
8
  import { clean, DEFAULT_RECORD, resolve, toLegacy, withLegacy, type CharColors, type LookRecord } from "./record.js";
9
9
  import type { BoneName } from "./skeleton.js";
10
+ import type { WornAdapter } from "./ctx.js";
11
+ import { isSocketName, placeSocket, SOCKETS, type SocketName } from "./sockets.js";
10
12
  import { followCanvas, inkMaterial, makeRamp, toonMaterial } from "./toon.js";
11
13
 
12
14
  // createCharacter: the character as ONE object for a plain Three.js game. It builds the player's record (record.ts) with the kit (make.ts), binds it
13
15
  // to the island's skeleton (assemble.ts), gives it the toon material and the ink line (toon.ts) and plays the island's idle, walk, run, jump and wave
14
- // on it (pose.ts, solver.ts) from the numbers the game gives each frame. The body only: a worn item is the host's (ctx.ts `WornAdapter`).
16
+ // on it (pose.ts, solver.ts) from the numbers the game gives each frame. A worn item is the host's (ctx.ts `WornAdapter`): its shapes merge into the
17
+ // body, or its own model hangs on one of the three sockets (sockets.ts), which stay on the new bones after a rebuild.
15
18
  // The blob shadow is a part of the body (a disc on the skeleton's shadow bone): it lies on the ground under the character in a jump and shrinks.
16
19
 
17
20
  /** The character's height in metres at the record's normal size: from the soles to the top of the hair (the Classic base). Another base, hair or size is not this tall: measure it with a Box3. */
@@ -33,6 +36,8 @@ export type CharacterOptions = {
33
36
  quality?: Quality;
34
37
  /** Starts with the walk's swing and bob off, as the island does for a player who asks for less motion. */
35
38
  reducedMotion?: boolean;
39
+ /** Worn things (ctx.ts `WornAdapter`): shapes merged into the body, or a model on a socket. Change them with `setWorn`. */
40
+ worn?: readonly WornAdapter[];
36
41
  };
37
42
 
38
43
  export interface Character {
@@ -63,6 +68,17 @@ export interface Character {
63
68
  setLook(next: LookRecord | Look): void;
64
69
  /** Paints these colours over the record's, or none (`null`): the record's own come back. It builds no geometry. */
65
70
  setColors(colors: Partial<CharColors> | null): void;
71
+ /**
72
+ * An attach point: "head" (at the base's hat frame, scaled with the head), "hand" (in the right hand, on grip_R) or "back" (on the chest,
73
+ * behind). Add your own object to it and it follows the bone. It is the same object for the character's whole life: a new base, hair or
74
+ * clothes (a rebuild) moves it, with what hangs on it, to the new bones and to the new base's frame. An unknown name throws.
75
+ */
76
+ socket(name: SocketName): THREE.Object3D;
77
+ /**
78
+ * Changes the worn things. Shapes that merge (`add`) and the hair's fit build new geometry; a thing that only hangs a model on a socket
79
+ * builds none. The objects of the things that are no longer worn come off their socket (the game frees them: the character never does).
80
+ */
81
+ setWorn(worn: readonly WornAdapter[]): void;
66
82
  /**
67
83
  * Frees the geometries, materials and textures that the character made, and takes `object` out of its parent. Later calls to the other methods do
68
84
  * nothing.
@@ -85,6 +101,22 @@ function checkColors(colors: unknown): Partial<CharColors> {
85
101
  return out;
86
102
  }
87
103
 
104
+ /** A worn thing that changes the body's geometry: its own shapes, or what it does to the hair, the badge, the tail or the wings. */
105
+ const merges = (w: WornAdapter) => !!(w.add || w.fit || w.hidesBadge || w.hideTail || w.hideWings);
106
+
107
+ function checkWorn(worn: unknown): readonly WornAdapter[] {
108
+ if (!Array.isArray(worn)) throw new TypeError("@nano133/nanoterra: worn must be a list of worn things");
109
+ worn.forEach((w: unknown, i) => {
110
+ const v = w as WornAdapter | null;
111
+ if (typeof v !== "object" || v === null) throw new TypeError(`@nano133/nanoterra: worn[${i}] is not an object`);
112
+ if (v.add !== undefined && typeof v.add !== "function") throw new TypeError(`@nano133/nanoterra: worn[${i}].add must be a function`);
113
+ if (v.socket !== undefined && !isSocketName(v.socket)) throw new TypeError(`@nano133/nanoterra: worn[${i}].socket must be one of ${SOCKETS.join(", ")}, not ${JSON.stringify(v.socket)}`);
114
+ if ((v.object === undefined) !== (v.socket === undefined)) throw new TypeError(`@nano133/nanoterra: worn[${i}] needs both a socket and an object, or neither`);
115
+ if (v.object !== undefined && !(v.object as THREE.Object3D)?.isObject3D) throw new TypeError(`@nano133/nanoterra: worn[${i}].object must be a THREE.Object3D`);
116
+ });
117
+ return [...worn];
118
+ }
119
+
88
120
  const frozen = (rec: LookRecord): Readonly<LookRecord> => Object.freeze({ ...clean(rec) });
89
121
 
90
122
  /** A record from a record or five colours; `from`: the record the five colours go into. Throws a TypeError that says what is wrong. */
@@ -110,6 +142,15 @@ export function createCharacter(options: CharacterOptions = {}): Character {
110
142
  }
111
143
  let record: LookRecord = opts.record !== undefined ? recordOf(opts.record, null) : opts.look !== undefined ? recordOf(opts.look, null) : { ...DEFAULT_RECORD };
112
144
  let over: Partial<CharColors> | null = opts.colors === undefined ? null : checkColors(opts.colors);
145
+ let worn: readonly WornAdapter[] = checkWorn(opts.worn ?? []);
146
+ /** The three sockets: made once, moved to the new bones on every rebuild. */
147
+ const sockets = Object.fromEntries(
148
+ SOCKETS.map((n) => {
149
+ const g = new THREE.Group();
150
+ g.name = `socket-${n}`;
151
+ return [n, g];
152
+ }),
153
+ ) as Record<SocketName, THREE.Group>;
113
154
 
114
155
  const ramp = makeRamp();
115
156
  const toon = toonMaterial(ramp);
@@ -125,7 +166,10 @@ export function createCharacter(options: CharacterOptions = {}): Character {
125
166
  type Rig = { made: Made; a: Assembled; pose: ReturnType<typeof makePose> };
126
167
  const colorsNow = (): CharColors => ({ ...resolve(record).colors, ...over });
127
168
  function build(): Rig {
128
- const made = buildWith(BASE_SPECS[resolve(record).base], resolve(record));
169
+ const look = resolve(record);
170
+ const spec = BASE_SPECS[look.base];
171
+ const merged = worn.filter(merges);
172
+ const made = buildWith(spec, look, merged.length ? { worn: merged } : {});
129
173
  paintCharacter(made, colorsNow());
130
174
  const a = assemble(made, { toon, ink: ink ?? toon });
131
175
  a.mesh.name = "character-toon";
@@ -139,10 +183,16 @@ export function createCharacter(options: CharacterOptions = {}): Character {
139
183
  a.group.scale.setScalar(HEIGHTS[record.height] ?? 1);
140
184
  object.add(a.group);
141
185
  const pose = makePose(a.list, a.bones as Record<BoneName, THREE.Bone>, unit);
186
+ for (const n of SOCKETS) placeSocket(sockets[n], a.bones, spec, look, n);
142
187
  return { made, a, pose };
143
188
  }
144
189
  let rig = build();
145
190
  rig.pose.rest(reducedMotion);
191
+ /** Hangs each worn model on its socket (in the order of the list). */
192
+ const hang = () => {
193
+ for (const w of worn) if (w.object && w.socket) sockets[w.socket].add(w.object);
194
+ };
195
+ hang();
146
196
 
147
197
  /** Frees a rig's geometry and skeleton. A rig that is replaced also leaves the object; a character that is disposed keeps its (dead) meshes, as it always did. */
148
198
  const free = (r: Rig, replaced: boolean) => {
@@ -201,10 +251,31 @@ export function createCharacter(options: CharacterOptions = {}): Character {
201
251
  over = colors === null ? null : checkColors(colors);
202
252
  paintCharacter(rig.made, colorsNow());
203
253
  },
254
+ socket(name) {
255
+ if (!isSocketName(name)) throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
256
+ return sockets[name];
257
+ },
258
+ setWorn(next) {
259
+ if (disposed) return;
260
+ const to = checkWorn(next);
261
+ const rebuild = worn.some(merges) || to.some(merges);
262
+ for (const w of worn) if (w.object && !to.some((x) => x.object === w.object)) w.object.removeFromParent();
263
+ worn = to;
264
+ if (rebuild) {
265
+ const was = rig;
266
+ rig = build();
267
+ Object.assign(rig.pose.state, was.pose.state);
268
+ rig.pose.rest(reducedMotion);
269
+ free(was, true);
270
+ }
271
+ hang();
272
+ },
204
273
  dispose() {
205
274
  if (disposed) return;
206
275
  disposed = true;
207
276
  object.removeFromParent();
277
+ // The sockets leave the dead bones; what hangs on them is the game's.
278
+ for (const n of SOCKETS) sockets[n].removeFromParent();
208
279
  free(rig, false);
209
280
  toon.dispose();
210
281
  ink?.dispose();
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
  import type { Builder, Paint, Weights } from "./build.js";
3
3
  import type { Limbs } from "./limbs.js";
4
4
  import type { CharLook } from "./record.js";
5
+ import type { SocketName } from "./sockets.js";
5
6
 
6
7
  // What a part gets to build with: the builder, the look, the size factors and the paint of each body segment (clothes.ts).
7
8
 
@@ -57,9 +58,21 @@ export type HatPart = { fit: HairFit; add(c: Ctx): void };
57
58
  * island's adapter (the shop's hats, scarves, packs, tops, buddies and blades, each fitted to every base) builds these; a game may bring its own.
58
59
  * `add` puts the thing's shapes and bones into the build after the body, in order, through the same context as the body's (`c.add`, `c.addHead`,
59
60
  * `c.b.bone`). A head item also says what it does to the hair (`fit`); the first that does decides. `hidesBadge`: the chest badge would poke through it.
60
- * `hideTail`: a back item (a cape, a pack, a plush) or a pinafore covers a base's tail.
61
+ * `hideTail`: a back item (a cape, a pack, a plush) or a pinafore covers a base's tail. `hideWings`: a back item covers a base's wings (the Bee's).
62
+ *
63
+ * A thing with its own model (a .glb, say) names a `socket` ("head", "hand" or "back", sockets.ts) and gives its `object`: `createCharacter`
64
+ * hangs the object on that socket (its own draws: 1 or 2 more for each thing) and still reads `fit`, `hidesBadge`, `hideTail` and `hideWings`. The game
65
+ * owns the object: the character never frees it. `add` is then optional (a thing can have both: merged shapes and a model).
61
66
  */
62
- export type WornAdapter = { fit?: HairFit; hidesBadge?: boolean; hideTail?: boolean; add(c: Ctx): void };
67
+ export type WornAdapter = {
68
+ fit?: HairFit;
69
+ hidesBadge?: boolean;
70
+ hideTail?: boolean;
71
+ hideWings?: boolean;
72
+ add?(c: Ctx): void;
73
+ socket?: SocketName;
74
+ object?: THREE.Object3D;
75
+ };
63
76
  /** A worn thing, by its older name (the same type as `WornAdapter`). */
64
77
  export type WornPart = WornAdapter;
65
78
 
@@ -79,6 +92,8 @@ export type Ctx = {
79
92
  hidesBadge: boolean;
80
93
  /** A worn back item covers the base's tail (a Critter's). */
81
94
  hideTail: boolean;
95
+ /** A worn back item covers the base's wings (a Bee's). */
96
+ hideWings: boolean;
82
97
  /** The base's arms and legs (a worn cuff follows the arm). */
83
98
  limbs: Limbs;
84
99
  /** Only the head and what sits on it (the pictures of faces and hats). */
@@ -27,3 +27,5 @@ export * from "./edit.js";
27
27
  export * from "./look.js";
28
28
  export { createCharacter, HEIGHT, RADIUS, type Character, type CharacterOptions, type Quality } from "./create.js";
29
29
  export { WAVE_SECONDS, MAX_DT, type Motion } from "./pose.js";
30
+ export { createLookPreview, type LookPreview, type LookPreviewOptions, type PreviewBody } from "./preview.js";
31
+ export { SOCKETS, HAND_HOLD, socketFrame, placeSocket, isSocketName, type SocketName, type SocketFrame, type SocketLook } from "./sockets.js";
@@ -47,6 +47,7 @@ export function makeContext(spec: BaseSpec, look: CharLook, b: Builder, opts: Ma
47
47
  fit: worn.find((w) => w.fit)?.fit ?? NO_FIT,
48
48
  hidesBadge: worn.some((w) => w.hidesBadge),
49
49
  hideTail: worn.some((w) => w.hideTail),
50
+ hideWings: worn.some((w) => w.hideWings),
50
51
  limbs: spec.limbs,
51
52
  headOnly,
52
53
  add,
@@ -69,7 +70,7 @@ export function buildWith(spec: BaseSpec, look: CharLook, opts: MakeOptions = {}
69
70
  // The shadow: a flat dark disc on its own bone, which stays on the floor under a hop.
70
71
  c.addBody(new THREE.CircleGeometry(0.62, 20), mtx(0, 0.02, 0, -Math.PI / 2), "shadow", "shadow", false);
71
72
  }
72
- for (const w of wornOf(opts)) w.add(c);
73
+ for (const w of wornOf(opts)) w.add?.(c);
73
74
  return { ...b.finish(), toonTris: b.p.length / 9, inkTris: b.ip.length / 9, look };
74
75
  }
75
76
  export type Made = ReturnType<typeof buildWith>;