@netforge/template-cards 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +71 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +28 -0
- package/README.md +82 -2
- package/client/decor.ts +8 -0
- package/client/index.html +11 -0
- package/client/main.ts +688 -0
- package/client/names.ts +58 -0
- package/llms-full.txt +243 -0
- package/llms.txt +94 -0
- package/package.json +79 -3
- package/src/game.ts +86 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +24 -0
package/client/main.ts
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INKWRIT client: a duel at a scribe's desk. Your hand is at the bottom
|
|
3
|
+
* (only you and the room ever see it), your inked creatures stand on their
|
|
4
|
+
* cards on your side of the desk, the other scribe's on theirs. Click a
|
|
5
|
+
* card to play it (spells that aim ask for a target), click one of your
|
|
6
|
+
* ready creatures and then a target to attack, End turn passes. Between
|
|
7
|
+
* duels you draft one of three new cards. Alone, an AI scribe of the
|
|
8
|
+
* gauntlet takes the other seat. The room decides every draw, hit and win.
|
|
9
|
+
*
|
|
10
|
+
* URL parameters: see @netforge/tabletop boot.ts.
|
|
11
|
+
*/
|
|
12
|
+
import { BoxGeometry, CanvasTexture, Color, ConeGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, PlaneGeometry, SRGBColorSpace } from "three/webgpu";
|
|
13
|
+
import type { InterpolatedEntity } from "@netforge/engine/net";
|
|
14
|
+
import { CardAct, RF, RulesKind, Target, UNITS } from "@netforge/engine/rules";
|
|
15
|
+
import type { RulesInput } from "@netforge/engine/rules";
|
|
16
|
+
import { bootBoard, cssColor, type BoardView, type Placed } from "@netforge/tabletop";
|
|
17
|
+
import { cardsAdapter, cardsGame, cardsManifest, COSMETICS, GAME_VERSION, PLACE, TEMPLATE } from "../src/manifest.js";
|
|
18
|
+
import { INKWRIT_COIN } from "../src/store.js";
|
|
19
|
+
import { LAMP_MODEL, SHELF_MODELS } from "./decor.js";
|
|
20
|
+
import { KEEPER_COLORS, RULE_NAMES, SKU_NAMES, TITLE, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
|
|
21
|
+
|
|
22
|
+
interface UnitV {
|
|
23
|
+
readonly type: number;
|
|
24
|
+
readonly attack: number;
|
|
25
|
+
readonly health: number;
|
|
26
|
+
readonly maxHealth: number;
|
|
27
|
+
readonly guard: boolean;
|
|
28
|
+
readonly ready: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface SideV {
|
|
31
|
+
readonly player: number;
|
|
32
|
+
readonly hero: number;
|
|
33
|
+
readonly heroMax: number;
|
|
34
|
+
readonly mana: number;
|
|
35
|
+
readonly maxMana: number;
|
|
36
|
+
readonly hand: number;
|
|
37
|
+
readonly deck: number;
|
|
38
|
+
readonly board: readonly UnitV[];
|
|
39
|
+
}
|
|
40
|
+
interface CardsView extends BoardView {
|
|
41
|
+
readonly stage: "waiting" | "duel" | "draft" | "done";
|
|
42
|
+
readonly duelNo: number;
|
|
43
|
+
readonly duels: number;
|
|
44
|
+
readonly countdown: number;
|
|
45
|
+
readonly duel: { readonly turn: number; readonly turnNo: number; readonly timer: number; readonly tier: number; readonly sides: readonly SideV[] } | null;
|
|
46
|
+
readonly log: readonly { p: number; kind: string; card: number; target: number }[];
|
|
47
|
+
readonly players: readonly { id: number; bot: boolean; wins: number; losses: number; draws: number; deck: number }[];
|
|
48
|
+
readonly hand?: readonly number[];
|
|
49
|
+
readonly offer?: readonly number[] | null;
|
|
50
|
+
readonly list?: readonly number[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const genre = cardsGame();
|
|
54
|
+
const g = genre.game;
|
|
55
|
+
const manifest = cardsManifest();
|
|
56
|
+
const FINISH = [0x2b2b2b, 0xb2382e, 0xd4af37];
|
|
57
|
+
const W = 18;
|
|
58
|
+
const H = 13;
|
|
59
|
+
const cardName = (type: number) => WEAPON_NAMES[g.cards[type]?.id ?? ""]?.name ?? g.cards[type]?.id ?? "?";
|
|
60
|
+
const cardLine = (type: number) => WEAPON_NAMES[g.cards[type]?.id ?? ""]?.line ?? "";
|
|
61
|
+
|
|
62
|
+
let lastView: CardsView | null = null;
|
|
63
|
+
let selfId = 0;
|
|
64
|
+
|
|
65
|
+
/** The browser's autopilot (?autopilot=1): draft the first offer; on our turn play what we can afford, attack, end. */
|
|
66
|
+
const autopilot = (tick: number): RulesInput | null => {
|
|
67
|
+
const v = lastView;
|
|
68
|
+
if (!v || tick % 12 !== 0) return null;
|
|
69
|
+
const go = (act: number, a = 0, b = 0, c = 0): RulesInput => ({ mx: 0, mz: 0, cx: 0, cz: 0, act, a, b, c });
|
|
70
|
+
if (v.stage === "draft" && v.offer) return go(CardAct.Draft, 0);
|
|
71
|
+
const d = v.duel;
|
|
72
|
+
if (!d || d.turn !== selfId) return null;
|
|
73
|
+
const me = d.sides.find((s) => s.player === selfId);
|
|
74
|
+
const foe = d.sides.find((s) => s.player !== selfId);
|
|
75
|
+
if (!me || !foe) return null;
|
|
76
|
+
const guards = foe.board.map((u, i) => ({ u, i })).filter((x) => x.u.guard);
|
|
77
|
+
const hand = v.hand ?? [];
|
|
78
|
+
for (let i = 0; i < hand.length; i++) {
|
|
79
|
+
const c = hand[i] as number;
|
|
80
|
+
const def = g.cards[c]?.def;
|
|
81
|
+
if (!def || def.cost > me.mana || (def.kind === "unit" && me.board.length >= g.table.boardMax)) continue;
|
|
82
|
+
let target: number = Target.None;
|
|
83
|
+
const fx = def.effect;
|
|
84
|
+
if (fx?.target === "enemy") target = guards[0] ? Target.EnemyUnit + guards[0].i : Target.EnemyHero;
|
|
85
|
+
if (fx?.target === "own_unit") {
|
|
86
|
+
if (me.board.length === 0) continue;
|
|
87
|
+
target = Target.OwnUnit;
|
|
88
|
+
}
|
|
89
|
+
return go(CardAct.Play, i, target, c);
|
|
90
|
+
}
|
|
91
|
+
const ready = me.board.findIndex((u) => u.ready && u.attack > 0);
|
|
92
|
+
if (ready >= 0) return go(CardAct.Attack, ready, guards[0] ? Target.EnemyUnit + guards[0].i : Target.EnemyHero);
|
|
93
|
+
return go(CardAct.End);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
async function main(): Promise<void> {
|
|
97
|
+
const app = await bootBoard({
|
|
98
|
+
adapter: cardsAdapter(),
|
|
99
|
+
manifest,
|
|
100
|
+
template: TEMPLATE,
|
|
101
|
+
place: PLACE,
|
|
102
|
+
version: GAME_VERSION,
|
|
103
|
+
labels: {
|
|
104
|
+
title: TITLE,
|
|
105
|
+
rules: RULE_NAMES,
|
|
106
|
+
skus: SKU_NAMES,
|
|
107
|
+
upgrades: UPGRADE_NAMES,
|
|
108
|
+
loading: "Mixing the ink",
|
|
109
|
+
help: "Click a card to play · click your creature, then a target, to attack · E or Enter ends the turn · Esc cancels",
|
|
110
|
+
},
|
|
111
|
+
theme: {
|
|
112
|
+
accent: "#e8c872",
|
|
113
|
+
gold: "#f2d48b",
|
|
114
|
+
ink: "#f3ead6",
|
|
115
|
+
dim: "#b3a68a",
|
|
116
|
+
panel: "rgba(28, 22, 18, 0.88)",
|
|
117
|
+
bg: "#17120e",
|
|
118
|
+
font: '"Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif',
|
|
119
|
+
display: '"Palatino Linotype", Palatino, Georgia, serif',
|
|
120
|
+
radius: 3,
|
|
121
|
+
},
|
|
122
|
+
coin: INKWRIT_COIN,
|
|
123
|
+
cosmetics: COSMETICS,
|
|
124
|
+
swatch: (c) => `linear-gradient(135deg, ${cssColor(FINISH[c] ?? 0x2b2b2b)}, #120c08)`,
|
|
125
|
+
ground: { w: W, h: H },
|
|
126
|
+
preset: "studio",
|
|
127
|
+
theme3d: "study",
|
|
128
|
+
groundColor: 0x3a2a1c,
|
|
129
|
+
pitch: 1.12,
|
|
130
|
+
// The duel is on a desk: no voxel meadow growing through the mat.
|
|
131
|
+
field: false,
|
|
132
|
+
autopilot,
|
|
133
|
+
soundKey: "inkwrit:sound",
|
|
134
|
+
});
|
|
135
|
+
selfId = app.self;
|
|
136
|
+
const { stage, hud, clock, sfx } = app;
|
|
137
|
+
sfx.define("flip", { wave: "noise", f0: 3000, f1: 900, ms: 70, gain: 0.3 }, { minGapMs: 40, voices: 3 });
|
|
138
|
+
sfx.define("quill", { wave: "triangle", f0: 1500, f1: 900, ms: 60, gain: 0.25 }, { minGapMs: 40, voices: 2 });
|
|
139
|
+
|
|
140
|
+
// ---- The desk: a wooden top with a felt mat, the piles, the lamp and the study's clutter beyond.
|
|
141
|
+
const desk = new Mesh(new BoxGeometry(W, 0.4, H), new MeshStandardMaterial({ color: 0x5a3a22, roughness: 0.7, metalness: 0.05 }));
|
|
142
|
+
desk.position.y = -0.2;
|
|
143
|
+
desk.receiveShadow = true;
|
|
144
|
+
stage.add(desk);
|
|
145
|
+
const mat = new Mesh(new PlaneGeometry(W - 2, H - 2), new MeshStandardMaterial({ map: matTexture(), roughness: 1 }));
|
|
146
|
+
mat.rotation.x = -Math.PI / 2;
|
|
147
|
+
mat.position.y = 0.01;
|
|
148
|
+
mat.receiveShadow = true;
|
|
149
|
+
stage.add(mat);
|
|
150
|
+
// The hand sits over the bottom of the screen: frame the desk a little lower.
|
|
151
|
+
stage.frameRect(0, 1.4, W, H + 2.5);
|
|
152
|
+
const lamp = stage.model(LAMP_MODEL, 0.8, 0xffb347, 1.4);
|
|
153
|
+
lamp.root.position.set(W / 2 - 1.4, 0, -H / 2 + 1.3);
|
|
154
|
+
stage.add(lamp.root);
|
|
155
|
+
SHELF_MODELS.forEach((id, i) => {
|
|
156
|
+
const m = stage.model(id, 1.6 + (i % 2) * 0.6, 0x7a5a3a, 1.8);
|
|
157
|
+
m.root.position.set(-W / 2 - 2 - (i % 2) * 1.5, 0, -H / 2 + 2 + i * 2.8);
|
|
158
|
+
stage.add(m.root);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Card meshes: face-down piles (decks) and the other scribe's hand, a face-up plinth under each creature.
|
|
162
|
+
const back = cardBackTexture();
|
|
163
|
+
const cardGeo = new BoxGeometry(1.5, 0.04, 2.1);
|
|
164
|
+
const backMat = new MeshStandardMaterial({ map: back, roughness: 0.8 });
|
|
165
|
+
const edgeMat = new MeshStandardMaterial({ color: 0xe8dcc0, roughness: 0.9 });
|
|
166
|
+
const cardMats = [edgeMat, edgeMat, backMat, backMat, edgeMat, edgeMat];
|
|
167
|
+
const piles: Group[] = [];
|
|
168
|
+
for (const z of [3.4, -3.4]) {
|
|
169
|
+
const pile = new Group();
|
|
170
|
+
pile.position.set(W / 2 - 2.2, 0.02, z);
|
|
171
|
+
stage.add(pile);
|
|
172
|
+
piles.push(pile);
|
|
173
|
+
}
|
|
174
|
+
const foeHand = new Group();
|
|
175
|
+
foeHand.position.set(1, 0.03, -5.2);
|
|
176
|
+
stage.add(foeHand);
|
|
177
|
+
const faceCache = new Map<number, CanvasTexture>();
|
|
178
|
+
const faceOf = (type: number) => {
|
|
179
|
+
let t = faceCache.get(type);
|
|
180
|
+
if (!t) faceCache.set(type, (t = cardFaceTexture(type)));
|
|
181
|
+
return t;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ---- Creatures on the board: a library model standing on its card, with a DOM badge (attack and health, and a button).
|
|
185
|
+
const units = new Map<string, { root: Group; model: Placed; type: number; card: Mesh; hp: number }>();
|
|
186
|
+
const heroes = new Map<number, { root: Group; hp: number }>();
|
|
187
|
+
const quills = new Map<number, Group>();
|
|
188
|
+
const overlay = document.createElement("div");
|
|
189
|
+
overlay.className = "hud";
|
|
190
|
+
overlay.style.inset = "0";
|
|
191
|
+
document.body.append(overlay);
|
|
192
|
+
const style = document.createElement("style");
|
|
193
|
+
style.textContent = `
|
|
194
|
+
.badge { position: absolute; transform: translate(-50%, -50%); pointer-events: auto; display: flex; gap: 3px; align-items: center; font: 800 13px var(--display, serif); cursor: pointer; }
|
|
195
|
+
.badge i { font-style: normal; min-width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; color: #fff; text-shadow: 0 1px 0 #000; border: 1px solid rgba(0,0,0,0.5); }
|
|
196
|
+
.badge i.a { background: #b8862f; }
|
|
197
|
+
.badge i.h { background: #a2312a; }
|
|
198
|
+
.badge.ready { filter: drop-shadow(0 0 6px #9dff7a); }
|
|
199
|
+
.badge.guard i.h { box-shadow: 0 0 0 2px #cfd8e3; }
|
|
200
|
+
.badge.target { filter: drop-shadow(0 0 7px #ff6a4a); animation: pulse 0.8s infinite alternate; }
|
|
201
|
+
.badge.picked { filter: drop-shadow(0 0 8px #f2d48b); transform: translate(-50%, -60%) scale(1.15); }
|
|
202
|
+
.hero { position: absolute; transform: translate(-50%, -50%); pointer-events: auto; text-align: center; font: 700 12px var(--display, serif); cursor: pointer; padding: 3px 9px; border-radius: 4px; background: var(--panel); border: 1px solid rgba(232, 200, 114, 0.4); }
|
|
203
|
+
.hero b { display: block; font-size: 20px; color: #ffb0a0; }
|
|
204
|
+
.hero.target { border-color: #ff6a4a; box-shadow: 0 0 12px #ff6a4a; }
|
|
205
|
+
@keyframes pulse { to { filter: drop-shadow(0 0 2px #ff6a4a); } }
|
|
206
|
+
#hand { position: fixed; left: 50%; bottom: max(8px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; gap: 6px; z-index: 6; }
|
|
207
|
+
.card { pointer-events: auto; width: min(112px, 13vw); aspect-ratio: 5 / 7; border-radius: 6px; padding: 6px; box-sizing: border-box; display: flex; flex-direction: column; gap: 3px; background: linear-gradient(#efe4c8, #dccaa0); color: #2a1d12; border: 2px solid #3a2a1c; cursor: pointer; transition: transform 120ms; text-align: left; font: 13px var(--display, serif); }
|
|
208
|
+
.card:hover, .card.picked { transform: translateY(-14px); }
|
|
209
|
+
.card.poor { filter: grayscale(0.7) brightness(0.8); }
|
|
210
|
+
.card .cost { align-self: flex-start; width: 22px; height: 22px; border-radius: 50%; background: #1f3550; color: #fff; display: grid; place-items: center; font-weight: 800; }
|
|
211
|
+
.card b { font-size: 13px; line-height: 1.05; }
|
|
212
|
+
.card small { font-size: 11px; color: #5a4632; flex: 1; }
|
|
213
|
+
.card .stats { display: flex; justify-content: space-between; font-weight: 800; }
|
|
214
|
+
#endturn { position: fixed; right: 14px; bottom: max(100px, calc(env(safe-area-inset-bottom) + 90px)); z-index: 6; pointer-events: auto; min-width: 118px; font-weight: 800; }
|
|
215
|
+
#mana { position: fixed; right: 14px; bottom: max(150px, calc(env(safe-area-inset-bottom) + 140px)); z-index: 6; font: 800 14px var(--display, serif); color: #9fd0ff; text-shadow: 0 1px 0 #000; }
|
|
216
|
+
#banner2 { left: 50%; top: 40%; transform: translate(-50%, -50%); font: 900 38px var(--display, serif); letter-spacing: 0.18em; color: var(--accent); text-shadow: 0 2px 0 #000; opacity: 0; transition: opacity 300ms; }
|
|
217
|
+
#banner2.show { opacity: 1; }
|
|
218
|
+
#draft { position: fixed; inset: 0; z-index: 7; display: none; place-items: center; background: rgba(0,0,0,0.45); }
|
|
219
|
+
#draft.show { display: grid; }
|
|
220
|
+
#draft .row { display: flex; gap: 14px; }
|
|
221
|
+
#draft .card { width: 140px; }
|
|
222
|
+
#draft h2 { text-align: center; font: 900 20px var(--display, serif); letter-spacing: 0.2em; color: var(--accent); margin: 0 0 12px; }
|
|
223
|
+
#loglines { position: fixed; top: max(12px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 5; font-size: 12px; color: var(--dim); text-align: center; }
|
|
224
|
+
@media (max-height: 500px) { .card { width: 78px; padding: 4px; font-size: 11px; } .card small { display: none; } #endturn { bottom: 90px; min-width: 90px; } #mana { bottom: 130px; } }
|
|
225
|
+
`;
|
|
226
|
+
document.head.append(style);
|
|
227
|
+
const handEl = document.createElement("div");
|
|
228
|
+
handEl.id = "hand";
|
|
229
|
+
document.body.append(handEl);
|
|
230
|
+
const endBtn = document.createElement("button");
|
|
231
|
+
endBtn.id = "endturn";
|
|
232
|
+
endBtn.className = "go";
|
|
233
|
+
endBtn.textContent = "End turn";
|
|
234
|
+
document.body.append(endBtn);
|
|
235
|
+
const manaEl = document.createElement("div");
|
|
236
|
+
manaEl.id = "mana";
|
|
237
|
+
document.body.append(manaEl);
|
|
238
|
+
const banner = document.createElement("div");
|
|
239
|
+
banner.id = "banner2";
|
|
240
|
+
banner.className = "hud";
|
|
241
|
+
document.body.append(banner);
|
|
242
|
+
const draftEl = document.createElement("div");
|
|
243
|
+
draftEl.id = "draft";
|
|
244
|
+
document.body.append(draftEl);
|
|
245
|
+
const logEl = document.createElement("div");
|
|
246
|
+
logEl.id = "loglines";
|
|
247
|
+
logEl.className = "hud";
|
|
248
|
+
document.body.append(logEl);
|
|
249
|
+
|
|
250
|
+
// ---- Choosing: a card to play or a creature to attack with, then (when it aims) a target.
|
|
251
|
+
type Pick = { kind: "play"; slot: number; type: number } | { kind: "attack"; from: number } | null;
|
|
252
|
+
let picked: Pick = null;
|
|
253
|
+
const myTurn = () => lastView?.duel?.turn === app.self && lastView.stage === "duel";
|
|
254
|
+
const sides = () => {
|
|
255
|
+
const d = lastView?.duel;
|
|
256
|
+
return { me: d?.sides.find((s) => s.player === app.self), foe: d?.sides.find((s) => s.player !== app.self) };
|
|
257
|
+
};
|
|
258
|
+
const send = (act: number, a = 0, b = 0, c = 0) => {
|
|
259
|
+
clock.act(act, a, b, c);
|
|
260
|
+
sfx.play("quill");
|
|
261
|
+
};
|
|
262
|
+
const target = (t: number) => {
|
|
263
|
+
const p = picked;
|
|
264
|
+
if (!p || !myTurn()) return;
|
|
265
|
+
if (p.kind === "play") send(CardAct.Play, p.slot, t, p.type);
|
|
266
|
+
else send(CardAct.Attack, p.from, t);
|
|
267
|
+
picked = null;
|
|
268
|
+
};
|
|
269
|
+
endBtn.addEventListener("click", () => {
|
|
270
|
+
if (!myTurn()) return;
|
|
271
|
+
picked = null;
|
|
272
|
+
send(CardAct.End);
|
|
273
|
+
});
|
|
274
|
+
addEventListener("keydown", (e) => {
|
|
275
|
+
if (e.code === "Escape") picked = null;
|
|
276
|
+
if ((e.code === "KeyE" || e.code === "Enter") && myTurn()) {
|
|
277
|
+
picked = null;
|
|
278
|
+
send(CardAct.End);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
app.canvas.addEventListener("pointermove", (e) => {
|
|
282
|
+
const p = stage.pick(e.clientX, e.clientY, app.canvas.getBoundingClientRect());
|
|
283
|
+
if (p) clock.cursor(p.x, p.z);
|
|
284
|
+
});
|
|
285
|
+
app.canvas.addEventListener("pointerdown", () => {
|
|
286
|
+
picked = null;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const unitX = (i: number, n: number) => 1 + (i - (n - 1) / 2) * 2.2;
|
|
290
|
+
const sideZ = (mine: boolean) => (mine ? 1.9 : -1.9);
|
|
291
|
+
const heroZ = (mine: boolean) => (mine ? 2.6 : -2.6);
|
|
292
|
+
|
|
293
|
+
let handKey = "";
|
|
294
|
+
let draftKey = "";
|
|
295
|
+
let lastTurn = -1;
|
|
296
|
+
let lastStage = "";
|
|
297
|
+
let lastLog = "";
|
|
298
|
+
let bannerTimer = 0;
|
|
299
|
+
const showBanner = (text: string) => {
|
|
300
|
+
banner.textContent = text;
|
|
301
|
+
banner.classList.add("show");
|
|
302
|
+
clearTimeout(bannerTimer);
|
|
303
|
+
bannerTimer = window.setTimeout(() => banner.classList.remove("show"), 1300);
|
|
304
|
+
};
|
|
305
|
+
const badges = new Map<string, HTMLElement>();
|
|
306
|
+
const heroTags = new Map<number, HTMLElement>();
|
|
307
|
+
|
|
308
|
+
app.loop((dt, now) => {
|
|
309
|
+
const v = app.view<CardsView>();
|
|
310
|
+
lastView = v;
|
|
311
|
+
const t = now / 1000;
|
|
312
|
+
const view = app.conn().net.render(now);
|
|
313
|
+
const wpx = app.canvas.clientWidth;
|
|
314
|
+
const hpx = app.canvas.clientHeight;
|
|
315
|
+
const proj = (x: number, y: number, z: number) => stage.project(x, y, z, wpx, hpx);
|
|
316
|
+
|
|
317
|
+
// Quills: ours predicted, the other scribe's interpolated.
|
|
318
|
+
const seen = new Set<number>();
|
|
319
|
+
const placeQuill = (e: InterpolatedEntity | undefined, owner: number) => {
|
|
320
|
+
if (!e) return;
|
|
321
|
+
seen.add(owner);
|
|
322
|
+
let q = quills.get(owner);
|
|
323
|
+
if (!q) {
|
|
324
|
+
q = quill(KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length] ?? 0x2f5d8a, FINISH[Math.round(e.values[RF.value] ?? 0)]);
|
|
325
|
+
quills.set(owner, q);
|
|
326
|
+
stage.add(q);
|
|
327
|
+
}
|
|
328
|
+
q.position.set((e.values[RF.x] ?? 0) / UNITS, 1.2 + Math.sin(t * 2.4 + owner) * 0.08, (e.values[RF.z] ?? 0) / UNITS);
|
|
329
|
+
};
|
|
330
|
+
placeQuill(view.self, app.self);
|
|
331
|
+
for (const e of view.others) if (e.kind === RulesKind.Avatar) placeQuill(e, e.owner);
|
|
332
|
+
for (const [o, q] of quills) {
|
|
333
|
+
if (seen.has(o)) continue;
|
|
334
|
+
stage.scene.remove(q);
|
|
335
|
+
quills.delete(o);
|
|
336
|
+
}
|
|
337
|
+
if (!v) return;
|
|
338
|
+
const d = v.duel;
|
|
339
|
+
const { me, foe } = sides();
|
|
340
|
+
|
|
341
|
+
// Creatures.
|
|
342
|
+
const want = new Set<string>();
|
|
343
|
+
for (const side of d?.sides ?? []) {
|
|
344
|
+
const mine = side.player === app.self;
|
|
345
|
+
side.board.forEach((u, i) => {
|
|
346
|
+
const key = `${side.player}:${i}:${u.type}`;
|
|
347
|
+
want.add(key);
|
|
348
|
+
const x = unitX(i, side.board.length);
|
|
349
|
+
const z = sideZ(mine);
|
|
350
|
+
let un = units.get(key);
|
|
351
|
+
if (!un) {
|
|
352
|
+
const root = new Group();
|
|
353
|
+
const c = new Mesh(cardGeo, [edgeMat, edgeMat, new MeshStandardMaterial({ map: faceOf(u.type), roughness: 0.85 }), backMat, edgeMat, edgeMat]);
|
|
354
|
+
c.position.y = 0.03;
|
|
355
|
+
root.add(c);
|
|
356
|
+
const model = stage.model(g.cards[u.type]?.def.model ?? "style.lowpoly.imp", 1.15, KEEPER_COLORS[(side.player - 1) % KEEPER_COLORS.length], 1.7);
|
|
357
|
+
model.root.position.y = 0.05;
|
|
358
|
+
if (!mine) model.root.rotation.y = Math.PI;
|
|
359
|
+
root.add(model.root);
|
|
360
|
+
root.position.set(x, 0, z);
|
|
361
|
+
stage.add(root);
|
|
362
|
+
un = { root, model, type: u.type, card: c, hp: u.health };
|
|
363
|
+
units.set(key, un);
|
|
364
|
+
stage.burst("magic", x, 0.6, z, 0x6fa8dc, 16);
|
|
365
|
+
sfx.play("flip");
|
|
366
|
+
}
|
|
367
|
+
un.root.position.x += (x - un.root.position.x) * Math.min(1, dt * 10);
|
|
368
|
+
if (u.health < un.hp) {
|
|
369
|
+
stage.burst("hitSpark", un.root.position.x, 0.8, z, 0xffd27a, 10);
|
|
370
|
+
sfx.play("hit");
|
|
371
|
+
floatText(proj(un.root.position.x, 1.8, z), `-${un.hp - u.health}`);
|
|
372
|
+
}
|
|
373
|
+
un.hp = u.health;
|
|
374
|
+
un.model.root.position.y = 0.05 + (u.ready && mine ? Math.abs(Math.sin(t * 4)) * 0.08 : 0);
|
|
375
|
+
// Its badge: attack and health, the button that picks it (yours) or aims at it (theirs).
|
|
376
|
+
let b = badges.get(key);
|
|
377
|
+
if (!b) {
|
|
378
|
+
b = document.createElement("div");
|
|
379
|
+
b.className = "badge";
|
|
380
|
+
b.innerHTML = `<i class="a"></i><i class="h"></i>`;
|
|
381
|
+
const idx = i;
|
|
382
|
+
b.addEventListener("pointerdown", (ev) => {
|
|
383
|
+
ev.stopPropagation();
|
|
384
|
+
sfx.unlock();
|
|
385
|
+
if (mine) {
|
|
386
|
+
if (picked?.kind === "play" && g.cards[picked.type]?.def.effect?.target === "own_unit") return target(Target.OwnUnit + idx);
|
|
387
|
+
if (myTurn()) picked = { kind: "attack", from: idx };
|
|
388
|
+
} else target(Target.EnemyUnit + idx);
|
|
389
|
+
});
|
|
390
|
+
overlay.append(b);
|
|
391
|
+
badges.set(key, b);
|
|
392
|
+
}
|
|
393
|
+
const p = proj(un.root.position.x, 0.2, z + (mine ? 1.2 : -1.2));
|
|
394
|
+
b.style.left = `${p.x}px`;
|
|
395
|
+
b.style.top = `${p.y}px`;
|
|
396
|
+
(b.firstChild as HTMLElement).textContent = String(u.attack);
|
|
397
|
+
(b.lastChild as HTMLElement).textContent = String(u.health);
|
|
398
|
+
b.classList.toggle("ready", mine && u.ready && myTurn());
|
|
399
|
+
b.classList.toggle("guard", u.guard);
|
|
400
|
+
b.classList.toggle("picked", mine && picked?.kind === "attack" && picked.from === i);
|
|
401
|
+
b.classList.toggle("target", !mine && picked !== null && (!foe?.board.some((x) => x.guard) || u.guard || picked.kind === "play"));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
for (const [key, un] of units) {
|
|
405
|
+
if (want.has(key)) continue;
|
|
406
|
+
stage.burst("kill", un.root.position.x, 0.6, un.root.position.z, 0x1f1a30, 22);
|
|
407
|
+
stage.scene.remove(un.root);
|
|
408
|
+
units.delete(key);
|
|
409
|
+
badges.get(key)?.remove();
|
|
410
|
+
badges.delete(key);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Heroes: the scribes themselves at each end of the desk, with their health as the target button.
|
|
414
|
+
const heroSeen = new Set<number>();
|
|
415
|
+
for (const side of d?.sides ?? []) {
|
|
416
|
+
const mine = side.player === app.self;
|
|
417
|
+
heroSeen.add(side.player);
|
|
418
|
+
let h = heroes.get(side.player);
|
|
419
|
+
if (!h) {
|
|
420
|
+
const root = new Group();
|
|
421
|
+
const m = stage.model(manifest.game.players.prefab, 2.2, KEEPER_COLORS[(side.player - 1) % KEEPER_COLORS.length]);
|
|
422
|
+
if (!mine) m.root.rotation.y = Math.PI;
|
|
423
|
+
root.add(m.root);
|
|
424
|
+
root.position.set(-W / 2 + 1.6, 0, heroZ(mine));
|
|
425
|
+
stage.add(root);
|
|
426
|
+
h = { root, hp: side.hero };
|
|
427
|
+
heroes.set(side.player, h);
|
|
428
|
+
}
|
|
429
|
+
if (side.hero < h.hp) {
|
|
430
|
+
stage.burst("burst", h.root.position.x, 1.4, h.root.position.z, 0xff6a4a, 18);
|
|
431
|
+
stage.kick(0.25);
|
|
432
|
+
sfx.play(mine ? "hurt" : "impact");
|
|
433
|
+
floatText(proj(h.root.position.x, 2.6, h.root.position.z), `-${h.hp - side.hero}`);
|
|
434
|
+
}
|
|
435
|
+
h.hp = side.hero;
|
|
436
|
+
let tag = heroTags.get(side.player);
|
|
437
|
+
if (!tag) {
|
|
438
|
+
tag = document.createElement("div");
|
|
439
|
+
tag.className = "hero";
|
|
440
|
+
tag.addEventListener("pointerdown", (ev) => {
|
|
441
|
+
ev.stopPropagation();
|
|
442
|
+
target(mine ? Target.OwnHero : Target.EnemyHero);
|
|
443
|
+
});
|
|
444
|
+
overlay.append(tag);
|
|
445
|
+
heroTags.set(side.player, tag);
|
|
446
|
+
}
|
|
447
|
+
const p = proj(h.root.position.x, 0, h.root.position.z + (mine ? 1.3 : -1.0));
|
|
448
|
+
tag.style.left = `${p.x}px`;
|
|
449
|
+
tag.style.top = `${p.y}px`;
|
|
450
|
+
const who = mine ? "You" : v.players.find((x) => x.id === side.player)?.bot ? `AI scribe, tier ${d ? d.tier + 1 : 1}` : `P${side.player}`;
|
|
451
|
+
tag.innerHTML = `<b>${side.hero}</b>${who}`;
|
|
452
|
+
tag.classList.toggle("target", !mine && picked !== null && !foe?.board.some((x) => x.guard));
|
|
453
|
+
}
|
|
454
|
+
for (const [p, h] of heroes) {
|
|
455
|
+
if (heroSeen.has(p)) continue;
|
|
456
|
+
stage.scene.remove(h.root);
|
|
457
|
+
heroes.delete(p);
|
|
458
|
+
heroTags.get(p)?.remove();
|
|
459
|
+
heroTags.delete(p);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Piles and the other scribe's face-down hand.
|
|
463
|
+
const stack = (pile: Group, n: number) => {
|
|
464
|
+
while (pile.children.length < Math.min(30, n)) {
|
|
465
|
+
const c = new Mesh(cardGeo, cardMats);
|
|
466
|
+
c.position.y = pile.children.length * 0.045;
|
|
467
|
+
c.rotation.y = (pile.children.length % 3) * 0.02;
|
|
468
|
+
pile.add(c);
|
|
469
|
+
}
|
|
470
|
+
while (pile.children.length > Math.min(30, n)) pile.remove(pile.children[pile.children.length - 1] as Mesh);
|
|
471
|
+
};
|
|
472
|
+
stack(piles[0] as Group, me?.deck ?? 0);
|
|
473
|
+
stack(piles[1] as Group, foe?.deck ?? 0);
|
|
474
|
+
const fh = foe?.hand ?? 0;
|
|
475
|
+
while (foeHand.children.length < fh) foeHand.add(new Mesh(cardGeo, cardMats));
|
|
476
|
+
while (foeHand.children.length > fh) foeHand.remove(foeHand.children[foeHand.children.length - 1] as Mesh);
|
|
477
|
+
foeHand.children.forEach((c, i) => {
|
|
478
|
+
const k = i - (fh - 1) / 2;
|
|
479
|
+
c.position.set(k * 0.9, 0.02 + i * 0.01, Math.abs(k) * 0.12);
|
|
480
|
+
c.rotation.y = -k * 0.08;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Your hand (DOM): only your view carries it.
|
|
484
|
+
const hand = v.hand ?? [];
|
|
485
|
+
const key = `${hand.join(",")}|${me?.mana ?? 0}|${myTurn()}|${picked?.kind === "play" ? picked.slot : -1}`;
|
|
486
|
+
if (key !== handKey) {
|
|
487
|
+
handKey = key;
|
|
488
|
+
handEl.innerHTML = "";
|
|
489
|
+
hand.forEach((type, slot) => {
|
|
490
|
+
const def = g.cards[type]?.def;
|
|
491
|
+
const el = cardEl(type);
|
|
492
|
+
if (!def || def.cost > (me?.mana ?? 0) || !myTurn()) el.classList.add("poor");
|
|
493
|
+
if (picked?.kind === "play" && picked.slot === slot) el.classList.add("picked");
|
|
494
|
+
el.addEventListener("click", (ev) => {
|
|
495
|
+
ev.stopPropagation();
|
|
496
|
+
sfx.unlock();
|
|
497
|
+
if (!myTurn() || !def) return;
|
|
498
|
+
if (def.cost > (me?.mana ?? 0)) return hud.toast("Not enough ink");
|
|
499
|
+
const aim = def.effect?.target;
|
|
500
|
+
if (aim === "enemy" || (aim === "own_unit" && (me?.board.length ?? 0) > 0)) {
|
|
501
|
+
picked = { kind: "play", slot, type };
|
|
502
|
+
handKey = "";
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
picked = null;
|
|
506
|
+
send(CardAct.Play, slot, Target.None, type);
|
|
507
|
+
});
|
|
508
|
+
handEl.append(el);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
// The draft between duels: three cards, only you see yours.
|
|
512
|
+
const offer = v.stage === "draft" ? (v.offer ?? null) : null;
|
|
513
|
+
const dk = offer ? offer.join(",") : "";
|
|
514
|
+
if (dk !== draftKey) {
|
|
515
|
+
draftKey = dk;
|
|
516
|
+
draftEl.innerHTML = "";
|
|
517
|
+
draftEl.classList.toggle("show", offer !== null && !app.preview);
|
|
518
|
+
if (offer) {
|
|
519
|
+
const box = document.createElement("div");
|
|
520
|
+
box.innerHTML = `<h2>DRAFT A CARD</h2><div class="row"></div>`;
|
|
521
|
+
offer.forEach((type, i) => {
|
|
522
|
+
const el = cardEl(type);
|
|
523
|
+
el.addEventListener("click", () => send(CardAct.Draft, i));
|
|
524
|
+
(box.lastChild as HTMLElement).append(el);
|
|
525
|
+
});
|
|
526
|
+
draftEl.append(box);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Turn changes, results, the log.
|
|
531
|
+
if (d && d.turnNo !== lastTurn) {
|
|
532
|
+
lastTurn = d.turnNo;
|
|
533
|
+
if (d.turn === app.self) {
|
|
534
|
+
showBanner("YOUR TURN");
|
|
535
|
+
sfx.play("pick");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (v.stage !== lastStage) {
|
|
539
|
+
if (v.stage === "draft" || v.stage === "done") {
|
|
540
|
+
const lastWin = v.log.filter((l) => l.kind === "win" || l.kind === "draw").at(-1);
|
|
541
|
+
if (lastWin) {
|
|
542
|
+
const won = lastWin.kind === "win" && lastWin.p === app.self;
|
|
543
|
+
showBanner(lastWin.kind === "draw" ? "DRAWN" : won ? "VICTORY" : "DEFEAT");
|
|
544
|
+
sfx.play(won ? "levelUp" : "hurt");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
lastStage = v.stage;
|
|
548
|
+
}
|
|
549
|
+
const lk = v.log.map((l) => `${l.p}${l.kind}${l.card}${l.target}`).join("|");
|
|
550
|
+
if (lk !== lastLog) {
|
|
551
|
+
lastLog = lk;
|
|
552
|
+
const l = v.log.at(-1);
|
|
553
|
+
logEl.textContent = l ? `${l.p === app.self ? "You" : "They"} ${l.kind === "play" ? `played ${cardName(l.card)}` : l.kind === "attack" ? `attacked with ${cardName(l.card)}` : l.kind === "end" ? "ended the turn" : l.kind === "win" ? "won the duel" : "drew"}` : "";
|
|
554
|
+
}
|
|
555
|
+
const mine = v.players.find((p) => p.id === app.self);
|
|
556
|
+
hud.status([
|
|
557
|
+
["DUEL", `${Math.max(1, v.duelNo)} / ${v.duels}`],
|
|
558
|
+
["RECORD", `${mine?.wins ?? 0}-${mine?.losses ?? 0}`],
|
|
559
|
+
v.stage === "duel" && d ? [d.turn === app.self ? "YOUR TURN" : "THEIR TURN", `${d.timer}s`] : v.stage === "waiting" ? ["WAITING", "for a scribe"] : v.stage === "draft" ? ["DRAFT", `${v.countdown}s`] : ["RUN", "over"],
|
|
560
|
+
["DECK", `${mine?.deck ?? 0} cards`],
|
|
561
|
+
]);
|
|
562
|
+
manaEl.textContent = me ? `INK ${me.mana} / ${me.maxMana}` : "";
|
|
563
|
+
endBtn.disabled = !myTurn();
|
|
564
|
+
endBtn.style.visibility = d ? "visible" : "hidden";
|
|
565
|
+
const won = (mine?.wins ?? 0) > (mine?.losses ?? 0);
|
|
566
|
+
hud.end(v.phase === "ended", won ? "THE PAGE IS YOURS" : "BLOTTED OUT", `${mine?.wins ?? 0} won, ${mine?.losses ?? 0} lost`, v.endsIn ?? null);
|
|
567
|
+
});
|
|
568
|
+
app.extraStats(() => ({ units: units.size, hand: lastView?.hand?.length ?? 0, duel: lastView?.duelNo ?? 0, stage: lastView?.stage ?? "" }));
|
|
569
|
+
|
|
570
|
+
function floatText(p: { x: number; y: number }, text: string): void {
|
|
571
|
+
const el = document.createElement("div");
|
|
572
|
+
el.className = "hud";
|
|
573
|
+
el.textContent = text;
|
|
574
|
+
el.style.cssText = `left:${p.x}px;top:${p.y}px;font:900 22px var(--display, serif);color:#ff8a6a;text-shadow:0 2px 0 #000;transform:translate(-50%,-50%);transition:transform 700ms ease-out, opacity 700ms;`;
|
|
575
|
+
document.body.append(el);
|
|
576
|
+
requestAnimationFrame(() => {
|
|
577
|
+
el.style.transform = "translate(-50%, -160%)";
|
|
578
|
+
el.style.opacity = "0";
|
|
579
|
+
});
|
|
580
|
+
setTimeout(() => el.remove(), 800);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** A hand or draft card as DOM: cost, name, its line, attack and health for a creature. */
|
|
585
|
+
function cardEl(type: number): HTMLButtonElement {
|
|
586
|
+
const def = g.cards[type]?.def;
|
|
587
|
+
const el = document.createElement("button");
|
|
588
|
+
el.className = "card";
|
|
589
|
+
el.innerHTML = `<span class="cost"></span><b></b><small></small><span class="stats"><span></span><span></span></span>`;
|
|
590
|
+
(el.querySelector(".cost") as HTMLElement).textContent = String(def?.cost ?? 0);
|
|
591
|
+
(el.querySelector("b") as HTMLElement).textContent = cardName(type);
|
|
592
|
+
(el.querySelector("small") as HTMLElement).textContent = `${def?.kind === "spell" ? "Spell. " : def?.guard ? "Guard. " : def?.rush ? "Rush. " : ""}${cardLine(type)}`;
|
|
593
|
+
const st = el.querySelectorAll(".stats span");
|
|
594
|
+
if (def?.kind === "unit") {
|
|
595
|
+
(st[0] as HTMLElement).textContent = `⚔ ${def.attack}`;
|
|
596
|
+
(st[1] as HTMLElement).textContent = `♥ ${def.health}`;
|
|
597
|
+
}
|
|
598
|
+
return el;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The quill each scribe points with: a feather on a nib, in the player's colour (a finish recolours the nib). */
|
|
602
|
+
function quill(color: number, finish: number | undefined): Group {
|
|
603
|
+
const q = new Group();
|
|
604
|
+
const vane = new Mesh(new ConeGeometry(0.16, 1.3, 6), new MeshStandardMaterial({ color: new Color(0xf2ead8), roughness: 0.8 }));
|
|
605
|
+
vane.position.y = 0.7;
|
|
606
|
+
const nib = new Mesh(new CylinderGeometry(0.04, 0.0, 0.35, 6), new MeshStandardMaterial({ color: finish ?? color, metalness: 0.6, roughness: 0.3, emissive: new Color(color), emissiveIntensity: 0.25 }));
|
|
607
|
+
nib.position.y = -0.05;
|
|
608
|
+
q.add(vane, nib);
|
|
609
|
+
q.rotation.z = 0.5;
|
|
610
|
+
return q;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function canvasTexture(w: number, h: number, draw: (g: CanvasRenderingContext2D) => void): CanvasTexture {
|
|
614
|
+
const c = document.createElement("canvas");
|
|
615
|
+
c.width = w;
|
|
616
|
+
c.height = h;
|
|
617
|
+
const ctx = c.getContext("2d");
|
|
618
|
+
if (!ctx) throw new Error("no 2d canvas");
|
|
619
|
+
draw(ctx);
|
|
620
|
+
const t = new CanvasTexture(c);
|
|
621
|
+
t.colorSpace = SRGBColorSpace;
|
|
622
|
+
return t;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function matTexture(): CanvasTexture {
|
|
626
|
+
return canvasTexture(512, 360, (c) => {
|
|
627
|
+
c.fillStyle = "#1f3a2e";
|
|
628
|
+
c.fillRect(0, 0, 512, 360);
|
|
629
|
+
let s = 9;
|
|
630
|
+
const r = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
631
|
+
for (let i = 0; i < 4000; i++) {
|
|
632
|
+
c.fillStyle = `rgba(${r() > 0.5 ? 40 : 20}, ${60 + r() * 20}, ${45 + r() * 15}, 0.5)`;
|
|
633
|
+
c.fillRect(r() * 512, r() * 360, 2, 2);
|
|
634
|
+
}
|
|
635
|
+
c.strokeStyle = "rgba(232, 200, 114, 0.55)";
|
|
636
|
+
c.lineWidth = 3;
|
|
637
|
+
c.strokeRect(14, 14, 484, 332);
|
|
638
|
+
c.setLineDash([6, 6]);
|
|
639
|
+
c.beginPath();
|
|
640
|
+
c.moveTo(30, 180);
|
|
641
|
+
c.lineTo(482, 180);
|
|
642
|
+
c.stroke();
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function cardBackTexture(): CanvasTexture {
|
|
647
|
+
return canvasTexture(150, 210, (c) => {
|
|
648
|
+
c.fillStyle = "#1f3550";
|
|
649
|
+
c.fillRect(0, 0, 150, 210);
|
|
650
|
+
c.strokeStyle = "#e8c872";
|
|
651
|
+
c.lineWidth = 6;
|
|
652
|
+
c.strokeRect(8, 8, 134, 194);
|
|
653
|
+
c.fillStyle = "#e8c872";
|
|
654
|
+
c.beginPath();
|
|
655
|
+
c.moveTo(75, 60);
|
|
656
|
+
c.quadraticCurveTo(110, 110, 75, 150);
|
|
657
|
+
c.quadraticCurveTo(40, 110, 75, 60);
|
|
658
|
+
c.fill();
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function cardFaceTexture(type: number): CanvasTexture {
|
|
663
|
+
const def = g.cards[type]?.def;
|
|
664
|
+
return canvasTexture(150, 210, (c) => {
|
|
665
|
+
c.fillStyle = "#efe4c8";
|
|
666
|
+
c.fillRect(0, 0, 150, 210);
|
|
667
|
+
c.strokeStyle = "#3a2a1c";
|
|
668
|
+
c.lineWidth = 6;
|
|
669
|
+
c.strokeRect(5, 5, 140, 200);
|
|
670
|
+
c.fillStyle = "#1f3550";
|
|
671
|
+
c.beginPath();
|
|
672
|
+
c.arc(26, 26, 16, 0, Math.PI * 2);
|
|
673
|
+
c.fill();
|
|
674
|
+
c.fillStyle = "#fff";
|
|
675
|
+
c.font = "bold 20px serif";
|
|
676
|
+
c.textAlign = "center";
|
|
677
|
+
c.fillText(String(def?.cost ?? 0), 26, 33);
|
|
678
|
+
c.fillStyle = "#2a1d12";
|
|
679
|
+
c.font = "bold 15px serif";
|
|
680
|
+
c.fillText(cardName(type), 75, 190);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
main().catch((e: unknown) => {
|
|
685
|
+
const t = document.getElementById("banner-text");
|
|
686
|
+
if (t) t.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
|
|
687
|
+
console.error(e);
|
|
688
|
+
});
|