@netforge/template-cards 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +28 -0
- package/README.md +46 -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.txt +94 -0
- package/package.json +54 -3
- package/src/game.ts +86 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
@netforge/template-cards (INKWRIT)
|
|
2
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
3
|
+
|
|
4
|
+
An original game. No names, characters, maps, models, sounds or code from
|
|
5
|
+
any other game or franchise.
|
|
6
|
+
|
|
7
|
+
Art and sound
|
|
8
|
+
- Models come from the per-style asset library
|
|
9
|
+
(@netforge/asset-library): CC0 1.0 models by Kenney and
|
|
10
|
+
Quaternius (monsters, animals, props), each pack listed with its
|
|
11
|
+
source URL and licence verification in that package's NOTICE and
|
|
12
|
+
manifest/packs.json. The files are fetched and built, never committed
|
|
13
|
+
here; the build copies only the models this game uses.
|
|
14
|
+
- Fallback shapes (@netforge/tabletop stage.ts) are generated from three.js
|
|
15
|
+
primitives; all textures are drawn on a canvas at load; sounds are the
|
|
16
|
+
library's CC0 samples with WebAudio synth fallbacks.
|
|
17
|
+
|
|
18
|
+
Dependencies (unmodified, via npm)
|
|
19
|
+
- three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
|
|
20
|
+
- @netforge/engine and @netforge/engine-sim (this repository);
|
|
21
|
+
see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
|
|
22
|
+
zod MIT).
|
|
23
|
+
|
|
24
|
+
Patterns (ideas only)
|
|
25
|
+
- The collectible card battler genre's general mechanics (mana that rises
|
|
26
|
+
each turn, units and spells, guards, drafting between matches). Mechanics, not
|
|
27
|
+
expression; no card, rule text, character or art was copied from any
|
|
28
|
+
game.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# INKWRIT (card battler template)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scribes duel in a lamplit study. Every card is an inked creature or a
|
|
4
|
+
spell written on a page. One against one, a person against a person or
|
|
5
|
+
against the gauntlet's AI scribes; a run is four duels with a draft of one
|
|
6
|
+
of three cards after each.
|
|
7
|
+
|
|
8
|
+
A board genre (`packages/engine/src/rules/cards.ts`) on the room server's
|
|
9
|
+
SimAdapter seam, drawn with `@netforge/tabletop`.
|
|
10
|
+
|
|
11
|
+
| Path | What |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `src/game.ts` | The game, as the model writes it (declarations only). |
|
|
14
|
+
| `src/manifest.ts` | Loads it; `cardsAdapter()` for the room server and client. Room template `cards` (two seats). |
|
|
15
|
+
| `src/store.ts` | Coin prices the client is wired to, the template's own coin (INK). |
|
|
16
|
+
| `client/` | The desk, creatures on their cards, piles, quills, the hand, targeting, the draft. |
|
|
17
|
+
| `test/` | Playtest bots and the store. |
|
|
18
|
+
| `llms.txt` | How to change the game (for agents). |
|
|
19
|
+
|
|
20
|
+
## How it plays on the server
|
|
21
|
+
|
|
22
|
+
- Server-authoritative at 30 Hz. Your quill (the cursor) is predicted; a
|
|
23
|
+
play, an attack, an end of turn and a draft pick are actions the room
|
|
24
|
+
validates (whose turn, the card in that slot, ink, guards).
|
|
25
|
+
- Hidden information: the room shuffles from its seeded PRNG and keeps
|
|
26
|
+
the deck order to itself; a hand and a draft offer go only to their
|
|
27
|
+
owner (`SimAdapter.infoFor`). Snapshots carry the quills only.
|
|
28
|
+
The room server's tests check it every few ticks of a run.
|
|
29
|
+
- A person alone for 3 s gets a seat bot (an AI scribe of the gauntlet's
|
|
30
|
+
tier); it leaves when a second person joins (that duel is called off).
|
|
31
|
+
- Earn (the room is the referee): duels won against a person (never a
|
|
32
|
+
forfeit), AI scribes beaten. The AI never earns. Pots stay off.
|
|
33
|
+
|
|
34
|
+
## Run it
|
|
35
|
+
|
|
36
|
+
This template runs on your own computer with play money through
|
|
37
|
+
`kit play` (`@netforge/kit-play`). From the repository root:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pnpm install --frozen-lockfile && pnpm build
|
|
41
|
+
mkdir -p play-cards/src && cp packages/template-cards/src/game.ts play-cards/src/
|
|
42
|
+
echo '{"template":"cards"}' > play-cards/netnet.json
|
|
43
|
+
node packages/kit-play/dist/cli.js play-cards # opens http://127.0.0.1:4663
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Edit `play-cards/src/game.ts` and run it again to try a change. Real-token
|
|
47
|
+
earning, stakes and claims run only on the hosted Net Forge server.
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the scene draws on its own (not named in game.ts): the
|
|
3
|
+
* study around the table. The build copies these into ./library in every
|
|
4
|
+
* art style (?style= may draw any).
|
|
5
|
+
*/
|
|
6
|
+
export const LAMP_MODEL = "style.lowpoly.campfire";
|
|
7
|
+
export const SHELF_MODELS = ["style.lowpoly.barrels", "style.lowpoly.crate", "style.lowpoly.chest", "style.lowpoly.column"] as const;
|
|
8
|
+
export const SCENE_MODELS: readonly string[] = [LAMP_MODEL, ...SHELF_MODELS];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
6
|
+
<title>Inkwrit</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<script type="module" src="./main.ts"></script>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
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
|
+
});
|
package/client/names.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display names for the template's content ids. Labels only; the rules are
|
|
3
|
+
* in src/game.ts. A Net Forge game generated on this template ships its own
|
|
4
|
+
* table (the model writes src/names.json; the build turns it into this
|
|
5
|
+
* module). Cards are the "weapons" table ({ name, line }).
|
|
6
|
+
*/
|
|
7
|
+
export const TITLE = "Inkwrit";
|
|
8
|
+
|
|
9
|
+
export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
10
|
+
blot_imp: { name: "Blot Imp", line: "Small and spiteful" },
|
|
11
|
+
quill_sprite: { name: "Quill Sprite", line: "Strikes at once" },
|
|
12
|
+
ink_frog: { name: "Ink Frog", line: "Sturdy for its cost" },
|
|
13
|
+
margin_bat: { name: "Margin Bat", line: "Strikes at once" },
|
|
14
|
+
ledger_crab: { name: "Ledger Crab", line: "Guards your hero" },
|
|
15
|
+
serif_fox: { name: "Serif Fox", line: "Draws a card" },
|
|
16
|
+
gloss_wolf: { name: "Gloss Wolf", line: "A solid hunter" },
|
|
17
|
+
stamp_golem: { name: "Stamp Golem", line: "A wall of ink" },
|
|
18
|
+
folio_wyrm: { name: "Folio Wyrm", line: "Scorches every foe" },
|
|
19
|
+
grand_errata: { name: "Grand Errata", line: "The last word" },
|
|
20
|
+
splash: { name: "Splash", line: "2 damage" },
|
|
21
|
+
blotting_sand: { name: "Blotting Sand", line: "Heal your hero 5" },
|
|
22
|
+
underline: { name: "Underline", line: "+2/+2 to a unit" },
|
|
23
|
+
second_draft: { name: "Second Draft", line: "Draw two cards" },
|
|
24
|
+
ink_storm: { name: "Ink Storm", line: "2 to every foe" },
|
|
25
|
+
final_word: { name: "Final Word", line: "5 damage" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
29
|
+
thick_vellum: { name: "Thick Vellum", line: "+5 hero health" },
|
|
30
|
+
sharp_nib: { name: "Sharp Nib", line: "+1 attack on your units" },
|
|
31
|
+
quick_ink: { name: "Quick Ink", line: "+1 mana in your first turn" },
|
|
32
|
+
spare_page: { name: "Spare Page", line: "+1 card in your first hand" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Creatures by prefab id (the duel has none; a game that adds them labels them here).
|
|
36
|
+
export const ENEMY_NAMES: Readonly<Record<string, string>> = {
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const RULE_NAMES: Readonly<Record<string, string>> = {
|
|
40
|
+
first_ink: "First Ink",
|
|
41
|
+
full_gauntlet: "Full Gauntlet",
|
|
42
|
+
inked_duel: "Inked Duel",
|
|
43
|
+
signed_and_sealed: "Signed and Sealed",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const SKU_NAMES: Readonly<Record<string, string>> = {
|
|
47
|
+
thick_vellum: "Thick Vellum",
|
|
48
|
+
sharp_nib: "Sharp Nib",
|
|
49
|
+
quick_ink: "Quick Ink",
|
|
50
|
+
spare_page: "Spare Page",
|
|
51
|
+
skin_vermilion: "Vermilion quill",
|
|
52
|
+
skin_gold_leaf: "Gold leaf quill",
|
|
53
|
+
bound_folio: "Bound folio",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Player colours by slot, and a highlight palette (the generated names module carries both). */
|
|
57
|
+
export const KEEPER_COLORS = [0x2f5d8a, 0xb2382e, 0x3f7a4a, 0x8a5a2b, 0x6b4a8a, 0xc9a227, 0x2a7a7a, 0x8a2a5a];
|
|
58
|
+
export const AURORA = { body: 0x3cf2c4, glow: 0xa77bff };
|
package/llms.txt
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# INKWRIT: the card battler template (NetNet game runtime, board genre)
|
|
2
|
+
|
|
3
|
+
> A one-against-one card battler. Two scribes duel at a desk: heroes with
|
|
4
|
+
> health, ink (mana) that rises each turn, a hand drawn from a shuffled
|
|
5
|
+
> deck, inked creatures on the board, spells. A run is a gauntlet of
|
|
6
|
+
> duels; after each one, every player drafts one of three cards into
|
|
7
|
+
> their deck. Alone, an AI scribe of the gauntlet takes the other seat.
|
|
8
|
+
> You change the game by editing src/game.ts only. The template already has
|
|
9
|
+
> the duel, turns and the turn timer, the shuffle (on the server), hidden
|
|
10
|
+
> hands, guards, rush, the draft, the AI, netcode, rendering, touch,
|
|
11
|
+
> earn and the store. Do not write renderers, netcode or AI.
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
- src/game.ts: THE GAME. Declarations against @netforge/engine:
|
|
16
|
+
game(), cardTable(), card(), upgrade(), earn.rule(), earn.stake(), store.sku().
|
|
17
|
+
- src/names.json: the title and every label players read. Cards go in
|
|
18
|
+
"weapons" ({ name, line }), plus upgrades, earn rules and store items.
|
|
19
|
+
- Everything else is template machinery.
|
|
20
|
+
|
|
21
|
+
## Rules the build enforces
|
|
22
|
+
|
|
23
|
+
- game({ template: "cards", style, places, players: { prefab, max } }), players.max at most 2.
|
|
24
|
+
- Exactly one cardTable(...); 4 to 40 card(...)s. The starter deck (8 to 30
|
|
25
|
+
cards) names declared cards, holds at least one unit and cards costing 2
|
|
26
|
+
or less. ai is 1 to 12 tiers { health, smart }.
|
|
27
|
+
- A unit has health 1 or more; a spell has an effect.
|
|
28
|
+
- Effects: kind "damage", "heal", "draw" or "buff"; target "enemy" (the
|
|
29
|
+
player picks an enemy unit or the hero), "enemy_hero", "all_enemies",
|
|
30
|
+
"own_hero", "own_unit" (the player picks one of theirs) or "all_own".
|
|
31
|
+
- Upgrades read four stats only: maxHealth (hero health), might (+attack on
|
|
32
|
+
the buyer's units), haste (+ink in the first turn), amount (+cards in
|
|
33
|
+
the first hand).
|
|
34
|
+
- No weapon(...). Asset ids look like "style.lowpoly.frog"; keep game({ style }).
|
|
35
|
+
- 2 playtest players must decide duels and fire an earn rule within 4 minutes.
|
|
36
|
+
|
|
37
|
+
## Units
|
|
38
|
+
|
|
39
|
+
Health and attack in points, cost in ink (0 to 10), seconds for timers.
|
|
40
|
+
Tick rate 30 Hz.
|
|
41
|
+
|
|
42
|
+
## The loop
|
|
43
|
+
|
|
44
|
+
A duel: a coin decides who goes first; the second player draws one extra
|
|
45
|
+
card. Each turn: +1 ink (up to manaMax), draw a card (an empty deck hurts
|
|
46
|
+
more each time), play cards, attack with ready units (a unit is ready the
|
|
47
|
+
turn after it lands, or at once with rush; guard units must be attacked
|
|
48
|
+
first), end the turn (or the timer ends it). A hero at 0 loses; turnCap
|
|
49
|
+
turns is a draw. After each duel but the last, a draft: one of three.
|
|
50
|
+
|
|
51
|
+
## Common changes
|
|
52
|
+
|
|
53
|
+
### A new card
|
|
54
|
+
card("mirror_moth", { model: "style.lowpoly.jelly_flyer", kind: "unit", cost: 3, attack: 2, health: 2, effect: { kind: "damage", amount: 1, target: "all_enemies" } });
|
|
55
|
+
Add its label to names.json weapons. The draft offers every card unless cardTable pool lists some.
|
|
56
|
+
|
|
57
|
+
### Tune the table
|
|
58
|
+
cardTable({ health: 25, duels: 5, turnSeconds: 40, ... }). Lower health
|
|
59
|
+
makes faster duels; more duels make longer runs.
|
|
60
|
+
|
|
61
|
+
### Harder or easier AI
|
|
62
|
+
ai: [{ health: 14, smart: 0.3 }, ...]: duel n against the AI uses tier n;
|
|
63
|
+
raise health and smart for a harder gauntlet.
|
|
64
|
+
|
|
65
|
+
### Earn rules
|
|
66
|
+
earn.rule("ink_master", { when: "ai.beaten", n: 3, pay: 15, oncePer: "run" });
|
|
67
|
+
Events: duel.won (a duel won against a person; never against the AI, never
|
|
68
|
+
a forfeit), ai.beaten (a gauntlet AI scribe beaten; a forfeit pays
|
|
69
|
+
nothing), run.won (more duels won than lost in the run). Each rule pays
|
|
70
|
+
once per run per player, in coins; the room server is the referee; the AI
|
|
71
|
+
scribe never earns.
|
|
72
|
+
|
|
73
|
+
### The buy-in run (required)
|
|
74
|
+
earn.stake({ min: 10, max: 100, multiple: 2 }); one top-level line. "stake" is reserved.
|
|
75
|
+
|
|
76
|
+
### Coins: earn and the store
|
|
77
|
+
store.sku("thick_vellum", { coins: 40 }); // one level of the upgrade for the run
|
|
78
|
+
store.sku("skin_gold_leaf", { coins: 500, grants: { cosmetic: 2 } }); // a finish for your quill, owned for good
|
|
79
|
+
store.sku("bound_folio", { coins: 120, grants: { gear: "thick_vellum", loseBelow: 0.25 } }); // losable gear
|
|
80
|
+
Keep the SKUs the client is wired to (thick_vellum, sharp_nib, quick_ink,
|
|
81
|
+
spare_page, skin_vermilion, skin_gold_leaf, bound_folio) at their prices.
|
|
82
|
+
|
|
83
|
+
## Hidden information
|
|
84
|
+
|
|
85
|
+
Decks are shuffled on the room server and their order never leaves it; a
|
|
86
|
+
hand and a draft offer are sent only to their owner. Nothing in game.ts
|
|
87
|
+
can change that.
|
|
88
|
+
|
|
89
|
+
## Test
|
|
90
|
+
|
|
91
|
+
engine.test.run({ ticks: 30 * 240, bots: 2 }) plays duels between two
|
|
92
|
+
players (bots: 1 plays one person against the AI) and reports duels won
|
|
93
|
+
(kills) and lost (deaths), the duel reached (wave), runs, earned by rule,
|
|
94
|
+
softlock, ms per tick and the genre's checks under rules.checks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/template-cards",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "INKWRIT: the card battler template of the NetNet game runtime, one against one or against the AI, a gauntlet of duels with a draft between them, server-side shuffle and hidden hands. The game module, its browser client and its playtest bots.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/manifest.ts",
|
|
9
|
+
"./store": "./src/store.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"client",
|
|
14
|
+
"!client/vite.config.ts",
|
|
15
|
+
"llms.txt",
|
|
16
|
+
"README.md",
|
|
17
|
+
"NOTICE",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/netnetdev/net-forge.git",
|
|
26
|
+
"directory": "packages/template-cards"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-cards#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"three": "0.186.1",
|
|
34
|
+
"@netforge/asset-library": "0.1.0",
|
|
35
|
+
"@netforge/tabletop": "0.1.0",
|
|
36
|
+
"@netforge/engine": "0.1.0",
|
|
37
|
+
"@netforge/engine-sim": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "22.20.1",
|
|
41
|
+
"@types/three": "0.186.0",
|
|
42
|
+
"tsx": "4.23.0",
|
|
43
|
+
"typescript": "5.8.3",
|
|
44
|
+
"vite": "6.4.3",
|
|
45
|
+
"vitest": "3.2.7"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
|
|
52
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"dev": "vite --config client/vite.config.ts",
|
|
55
|
+
"preview": "vite preview --config client/vite.config.ts"
|
|
56
|
+
}
|
|
6
57
|
}
|
package/src/game.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INKWRIT: the card battler template (play style:
|
|
3
|
+
* card battler).
|
|
4
|
+
*
|
|
5
|
+
* Scribes duel in a lamplit study. Every card is an inked creature or a
|
|
6
|
+
* spell written on a page: blot imps, ledger crabs, folio wyrms, a splash
|
|
7
|
+
* of ink. One against one, a person against a person or against the
|
|
8
|
+
* gauntlet's AI scribes. A run is four duels; after each, you draft one of
|
|
9
|
+
* three new cards into your deck.
|
|
10
|
+
*
|
|
11
|
+
* This file is the whole game as the model writes it: declarations only,
|
|
12
|
+
* against the typed API in @netforge/engine. The duel, the draft, the
|
|
13
|
+
* shuffle, hidden hands, the AI, netcode, renderer and referee are the
|
|
14
|
+
* template's machinery.
|
|
15
|
+
*/
|
|
16
|
+
import { card, cardTable, earn, game, store, upgrade } from "@netforge/engine";
|
|
17
|
+
|
|
18
|
+
export default game({
|
|
19
|
+
template: "cards",
|
|
20
|
+
style: "lowpoly",
|
|
21
|
+
places: { study: { chunks: "places/study", spawn: [0, 0, 0] } },
|
|
22
|
+
players: { prefab: "style.lowpoly.hooded_adventurer", max: 2 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The table: hero health, hands, the board, the gauntlet and its AI scribes (tier n plays duel n against the AI).
|
|
26
|
+
cardTable({
|
|
27
|
+
health: 20,
|
|
28
|
+
startHand: 3,
|
|
29
|
+
handMax: 7,
|
|
30
|
+
boardMax: 5,
|
|
31
|
+
manaMax: 10,
|
|
32
|
+
turnSeconds: 30,
|
|
33
|
+
draftSeconds: 15,
|
|
34
|
+
duels: 4,
|
|
35
|
+
starter: ["blot_imp", "blot_imp", "quill_sprite", "quill_sprite", "ink_frog", "ink_frog", "margin_bat", "ledger_crab", "serif_fox", "splash", "splash", "blotting_sand"],
|
|
36
|
+
ai: [
|
|
37
|
+
{ health: 14, smart: 0.3 },
|
|
38
|
+
{ health: 16, smart: 0.55 },
|
|
39
|
+
{ health: 20, smart: 0.75 },
|
|
40
|
+
{ health: 24, smart: 0.9 },
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Units: cost in mana, attack and health. guard units must be attacked first; rush units attack the turn they land.
|
|
45
|
+
card("blot_imp", { model: "style.lowpoly.imp", kind: "unit", cost: 1, attack: 2, health: 1 });
|
|
46
|
+
card("quill_sprite", { model: "style.lowpoly.bee", kind: "unit", cost: 1, attack: 1, health: 2, rush: true });
|
|
47
|
+
card("ink_frog", { model: "style.lowpoly.frog", kind: "unit", cost: 2, attack: 2, health: 3 });
|
|
48
|
+
card("margin_bat", { model: "style.lowpoly.bat", kind: "unit", cost: 2, attack: 3, health: 1, rush: true });
|
|
49
|
+
card("ledger_crab", { model: "style.lowpoly.crab", kind: "unit", cost: 3, attack: 2, health: 5, guard: true });
|
|
50
|
+
card("serif_fox", { model: "style.lowpoly.fox", kind: "unit", cost: 3, attack: 3, health: 3, effect: { kind: "draw", amount: 1 } });
|
|
51
|
+
card("gloss_wolf", { model: "style.lowpoly.wolf", kind: "unit", cost: 4, attack: 4, health: 4 });
|
|
52
|
+
card("stamp_golem", { model: "style.lowpoly.yeti", kind: "unit", cost: 5, attack: 4, health: 7, guard: true });
|
|
53
|
+
card("folio_wyrm", { model: "style.lowpoly.dragon_whelp", kind: "unit", cost: 6, attack: 5, health: 5, effect: { kind: "damage", amount: 2, target: "all_enemies" } });
|
|
54
|
+
card("grand_errata", { model: "style.lowpoly.dragon", kind: "unit", cost: 8, attack: 8, health: 8 });
|
|
55
|
+
// Spells: an effect only. "enemy" lets the player pick an enemy unit or the hero.
|
|
56
|
+
card("splash", { model: "style.lowpoly.crystal", kind: "spell", cost: 1, effect: { kind: "damage", amount: 2, target: "enemy" } });
|
|
57
|
+
card("blotting_sand", { model: "style.lowpoly.gem_mid", kind: "spell", cost: 2, effect: { kind: "heal", amount: 5, target: "own_hero" } });
|
|
58
|
+
card("underline", { model: "style.lowpoly.gem_pink", kind: "spell", cost: 2, effect: { kind: "buff", amount: 2, target: "own_unit" } });
|
|
59
|
+
card("second_draft", { model: "style.lowpoly.chest", kind: "spell", cost: 3, effect: { kind: "draw", amount: 2 } });
|
|
60
|
+
card("ink_storm", { model: "style.lowpoly.meteor", kind: "spell", cost: 4, effect: { kind: "damage", amount: 2, target: "all_enemies" } });
|
|
61
|
+
card("final_word", { model: "style.lowpoly.crystal_rock", kind: "spell", cost: 5, effect: { kind: "damage", amount: 5, target: "enemy" } });
|
|
62
|
+
|
|
63
|
+
// Store upgrades: one level each, for the rest of the run (hero health, unit attack, first-turn mana, starting hand).
|
|
64
|
+
upgrade("thick_vellum", { stat: "maxHealth", step: 5, maxLevel: 1 });
|
|
65
|
+
upgrade("sharp_nib", { stat: "might", step: 1, maxLevel: 1 });
|
|
66
|
+
upgrade("quick_ink", { stat: "haste", step: 1, maxLevel: 1 });
|
|
67
|
+
upgrade("spare_page", { stat: "amount", step: 1, maxLevel: 1 });
|
|
68
|
+
|
|
69
|
+
// Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each. Values are PLACEHOLDER.
|
|
70
|
+
// duel.won counts duels won against a person (never against the AI, never a forfeit); ai.beaten counts gauntlet AI scribes beaten.
|
|
71
|
+
earn.rule("first_ink", { when: "ai.beaten", n: 1, pay: 5, oncePer: "run" });
|
|
72
|
+
earn.rule("full_gauntlet", { when: "ai.beaten", n: 4, pay: 20, oncePer: "run" });
|
|
73
|
+
earn.rule("inked_duel", { when: "duel.won", n: 1, pay: 12, oncePer: "run" });
|
|
74
|
+
earn.rule("signed_and_sealed", { when: "duel.won", n: 3, pay: 25, oncePer: "run" });
|
|
75
|
+
// Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play. Values are PLACEHOLDER.
|
|
76
|
+
earn.stake({ min: 10, max: 100, multiple: 2 });
|
|
77
|
+
|
|
78
|
+
// Store (the Kit), priced in coins: four run upgrades and two finishes for your quill, owned for good.
|
|
79
|
+
store.sku("thick_vellum", { coins: 40 });
|
|
80
|
+
store.sku("sharp_nib", { coins: 60 });
|
|
81
|
+
store.sku("quick_ink", { coins: 40 });
|
|
82
|
+
store.sku("spare_page", { coins: 50 });
|
|
83
|
+
store.sku("skin_vermilion", { coins: 300, grants: { cosmetic: 1 } });
|
|
84
|
+
store.sku("skin_gold_leaf", { coins: 500, grants: { cosmetic: 2 } });
|
|
85
|
+
// Losable gear: one level of its upgrade in every staked run, lost when that run makes under loseBelow of its most earn. PLACEHOLDER.
|
|
86
|
+
store.sku("bound_folio", { coins: 120, grants: { gear: "thick_vellum", loseBelow: 0.25 } });
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the game module once per process and compiles it. The room server,
|
|
3
|
+
* the client, the bots and the tests all start here.
|
|
4
|
+
*/
|
|
5
|
+
import { collectManifest } from "@netforge/engine/game";
|
|
6
|
+
import type { GameManifest } from "@netforge/engine";
|
|
7
|
+
import { rulesAdapter, cardsGenre } from "@netforge/engine/rules";
|
|
8
|
+
import type { RulesInput, RulesWorld, CardsState } from "@netforge/engine/rules";
|
|
9
|
+
import type { SimAdapter } from "@netforge/engine/net";
|
|
10
|
+
import "./game.js";
|
|
11
|
+
|
|
12
|
+
/** Content version: bump with any change to game.ts (hot swap compares it). */
|
|
13
|
+
export const GAME_VERSION = "inkwrit-0.1.0";
|
|
14
|
+
export const GAME_NAME = "inkwrit";
|
|
15
|
+
/** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
|
|
16
|
+
export const TEMPLATE = "cards";
|
|
17
|
+
export const PLACE = "study";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collected as soon as the module is imported: one process may load more
|
|
21
|
+
* than one game module (the room server hosts every template), and each
|
|
22
|
+
* manifest takes its own game before the next module declares.
|
|
23
|
+
*/
|
|
24
|
+
const MANIFEST = collectManifest();
|
|
25
|
+
let genre: ReturnType<typeof cardsGenre> | null = null;
|
|
26
|
+
|
|
27
|
+
/** The game's manifest (every declaration). */
|
|
28
|
+
export function cardsManifest(): GameManifest {
|
|
29
|
+
return MANIFEST;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The compiled card battler genre (table, cards, AI). */
|
|
33
|
+
export function cardsGame(): ReturnType<typeof cardsGenre> {
|
|
34
|
+
genre ??= cardsGenre(MANIFEST);
|
|
35
|
+
return genre;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The SimAdapter the room server and the client both run. */
|
|
39
|
+
export function cardsAdapter(): SimAdapter<RulesWorld<CardsState>, RulesInput> {
|
|
40
|
+
return rulesAdapter(cardsGame(), MANIFEST, { name: GAME_NAME, version: GAME_VERSION });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cosmetic ids for store SKUs (0 is the default finish). */
|
|
44
|
+
export const COSMETICS: Readonly<Record<string, number>> = { skin_vermilion: 1, skin_gold_leaf: 2 };
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store hooks (the Coins model): every
|
|
3
|
+
* game has coins, 1 coin = 1 game token, shown under the token's own name,
|
|
4
|
+
* symbol and logo. INKWRIT sells four run upgrades (a store command named
|
|
5
|
+
* after the upgrade grants one level for the rest of the run), two quill
|
|
6
|
+
* finishes (permanent) and one piece of losable gear, all priced in coins.
|
|
7
|
+
* The Kit bridge and its in-memory stand-in live in @netforge/tabletop.
|
|
8
|
+
*/
|
|
9
|
+
export { mockKit } from "@netforge/tabletop";
|
|
10
|
+
export type { KitStoreBridge, MockKit, SkuPrice } from "@netforge/tabletop";
|
|
11
|
+
|
|
12
|
+
/** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
|
|
13
|
+
export const COIN_PRICES = { thick_vellum: 40, sharp_nib: 60, quick_ink: 40, spare_page: 50, skin_vermilion: 300, skin_gold_leaf: 500, bound_folio: 120 } as const;
|
|
14
|
+
/** Run upgrades: spent, applied by the room for the rest of the run. */
|
|
15
|
+
export const UPGRADE_SKUS = ["thick_vellum", "sharp_nib", "quick_ink", "spare_page"] as const;
|
|
16
|
+
/** Cosmetic finishes: bought once, owned for good. */
|
|
17
|
+
export const COSMETIC_SKUS = ["skin_vermilion", "skin_gold_leaf"] as const;
|
|
18
|
+
|
|
19
|
+
/** The template's own coin, as its token metadata names it until a creator renames it. */
|
|
20
|
+
export const INKWRIT_COIN = {
|
|
21
|
+
name: "Ink",
|
|
22
|
+
symbol: "INK",
|
|
23
|
+
logo: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%231f3550'/%3E%3Cpath d='M16 5c4 6 7 10 7 14a7 7 0 0 1-14 0c0-4 3-8 7-14z' fill='%23e8dcc0'/%3E%3C/svg%3E",
|
|
24
|
+
} as const;
|