@netforge/template-arena 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 +34 -0
- package/README.md +63 -2
- package/client/audio.ts +43 -0
- package/client/decor.ts +22 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +330 -0
- package/client/index.html +230 -0
- package/client/input.ts +233 -0
- package/client/library.ts +146 -0
- package/client/main.ts +544 -0
- package/client/names.ts +43 -0
- package/client/palette.ts +35 -0
- package/client/scene.ts +1075 -0
- package/client/stake.ts +143 -0
- package/llms.txt +164 -0
- package/package.json +54 -3
- package/src/game.ts +117 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +109 -0
|
@@ -0,0 +1,230 @@
|
|
|
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>Voltyard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--ink: #e9f3ff;
|
|
11
|
+
--dim: #8b98ad;
|
|
12
|
+
--cyan: #16e0ff;
|
|
13
|
+
--pink: #ff4fd8;
|
|
14
|
+
--amber: #ffb020;
|
|
15
|
+
--panel: rgba(8, 12, 22, 0.78);
|
|
16
|
+
--line: rgba(22, 224, 255, 0.22);
|
|
17
|
+
--bad: #ff5a4f;
|
|
18
|
+
font-family: "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
19
|
+
}
|
|
20
|
+
html, body { margin: 0; height: 100%; background: #0b1020; color: var(--ink); overflow: hidden; touch-action: none; -webkit-user-select: none; user-select: none; }
|
|
21
|
+
#game { position: fixed; inset: 0; cursor: none; }
|
|
22
|
+
#game canvas { display: block; width: 100%; height: 100%; }
|
|
23
|
+
.hud { position: fixed; z-index: 5; pointer-events: none; font-variant-numeric: tabular-nums; }
|
|
24
|
+
.hud button, .hud .slot { pointer-events: auto; }
|
|
25
|
+
button { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 7px 11px; cursor: pointer; }
|
|
26
|
+
button:disabled { opacity: 0.45; cursor: default; }
|
|
27
|
+
button:active { transform: scale(0.97); }
|
|
28
|
+
|
|
29
|
+
#top-left { top: max(12px, env(safe-area-inset-top)); left: 12px; width: min(300px, 40vw); }
|
|
30
|
+
#title { font-weight: 800; letter-spacing: 0.24em; font-size: 12px; color: var(--cyan); }
|
|
31
|
+
.bar { position: relative; height: 12px; border-radius: 3px; background: rgba(255, 255, 255, 0.08); overflow: hidden; margin-top: 6px; transform: skewX(-12deg); }
|
|
32
|
+
.bar > div { position: absolute; inset: 0; transform-origin: left; transition: transform 90ms linear; }
|
|
33
|
+
#hp-fill { background: linear-gradient(90deg, #ff4fd8, #16e0ff); }
|
|
34
|
+
#hp-text { position: absolute; right: 8px; top: -1px; font-size: 10px; font-weight: 800; color: #fff; text-shadow: 0 1px 2px #000; transform: skewX(12deg); }
|
|
35
|
+
#buffs { margin-top: 5px; font-size: 10px; letter-spacing: 0.16em; color: var(--amber); text-transform: uppercase; min-height: 12px; }
|
|
36
|
+
|
|
37
|
+
#slots { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; gap: 8px; z-index: 5; }
|
|
38
|
+
.slot { width: min(150px, 28vw); padding: 8px 10px 9px; border-radius: 8px; background: var(--panel); border: 1px solid rgba(255, 255, 255, 0.1); text-align: left; display: grid; gap: 2px; opacity: 0.62; transition: opacity 120ms, border-color 120ms, transform 120ms; }
|
|
39
|
+
.slot.on { opacity: 1; border-color: var(--cyan); transform: translateY(-3px); box-shadow: 0 0 18px rgba(22, 224, 255, 0.25); }
|
|
40
|
+
.slot kbd { font: 700 10px ui-monospace, monospace; color: var(--cyan); }
|
|
41
|
+
.slot b { font-size: 13px; letter-spacing: 0.04em; }
|
|
42
|
+
.slot span { font-size: 11px; color: var(--dim); }
|
|
43
|
+
.slot .cd { display: block; height: 3px; background: rgba(255, 255, 255, 0.08); border-radius: 2px; overflow: hidden; margin-top: 3px; }
|
|
44
|
+
.slot .cd > i { display: block; height: 100%; background: var(--amber); transform-origin: left; transform: scaleX(0); }
|
|
45
|
+
|
|
46
|
+
#top-center { top: max(8px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); text-align: center; }
|
|
47
|
+
#timer { font-size: 30px; font-weight: 800; letter-spacing: 0.06em; text-shadow: 0 0 14px rgba(22, 224, 255, 0.5); }
|
|
48
|
+
#timer.low { color: var(--pink); }
|
|
49
|
+
#goal { font-size: 10px; letter-spacing: 0.24em; color: var(--dim); }
|
|
50
|
+
|
|
51
|
+
#top-right { top: max(10px, env(safe-area-inset-top)); right: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: min(240px, 46vw); }
|
|
52
|
+
#top-right .row { display: flex; gap: 6px; }
|
|
53
|
+
/* The coin sits on its own dark plate, so the arena's hazard lines never run through it. */
|
|
54
|
+
#coin-box { display: flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 8px; background: rgba(8, 12, 22, 0.88); border: 1px solid var(--line); font-weight: 700; white-space: nowrap; }
|
|
55
|
+
#coin-box.guest #coin-balance { color: var(--dim); font-weight: 600; }
|
|
56
|
+
/* Play money: a second line inside the plate, so the plate (and the Store row under it) makes room for it. */
|
|
57
|
+
#coin-box.play { display: block; text-align: right; }
|
|
58
|
+
#coin-box.play #coin-logo { vertical-align: middle; margin-right: 6px; }
|
|
59
|
+
#coin-box.play::after { content: "play money"; display: block; margin-top: 1px; font-size: 10px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); }
|
|
60
|
+
#end-earned li.guest { color: var(--dim); }
|
|
61
|
+
#coin-logo { border-radius: 50%; }
|
|
62
|
+
#board { list-style: none; padding: 6px 8px; margin: 0; width: 100%; box-sizing: border-box; background: var(--panel); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; font-size: 12px; }
|
|
63
|
+
#board { width: auto; min-width: 150px; }
|
|
64
|
+
#board:empty { display: none; }
|
|
65
|
+
#board-full { list-style: none; margin: 0; display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(420px, 90vw); box-sizing: border-box; font-size: 14px; padding: 14px 16px; z-index: 9; background: rgba(8, 12, 22, 0.94); border: 1px solid var(--line); border-radius: 10px; }
|
|
66
|
+
#board-full.open { display: block; }
|
|
67
|
+
#board li, #board-full li { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
|
|
68
|
+
#board-full li { padding: 4px 0; }
|
|
69
|
+
#board-full li.head { color: var(--dim); font-size: 9px; letter-spacing: 0.18em; }
|
|
70
|
+
#board li.me, #board-full li.me { color: var(--cyan); font-weight: 700; }
|
|
71
|
+
#board em, #board-full em { font-style: normal; color: var(--dim); width: 14px; }
|
|
72
|
+
#board .dot, #board-full .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c, transparent); }
|
|
73
|
+
#board b, #board-full b { margin-left: auto; min-width: 22px; text-align: right; }
|
|
74
|
+
#board-full small { min-width: 20px; text-align: right; color: var(--dim); }
|
|
75
|
+
#board-full i.bot { font-style: normal; font-size: 9px; letter-spacing: 0.14em; color: var(--dim); border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 4px; padding: 0 4px; }
|
|
76
|
+
#arrows { inset: 0; }
|
|
77
|
+
#arrows i { position: absolute; left: 0; top: 0; width: 0; height: 0; margin: -9px 0 0 -6px; border-top: 9px solid transparent; border-bottom: 9px solid transparent; border-left: 16px solid var(--c); filter: drop-shadow(0 0 6px var(--c)); transform-origin: 6px 9px; }
|
|
78
|
+
#feed { list-style: none; padding: 0; margin: 0; font-size: 12px; font-weight: 700; text-align: right; }
|
|
79
|
+
#feed li { padding: 2px 6px; margin-top: 3px; background: rgba(8, 12, 22, 0.6); border-radius: 5px; }
|
|
80
|
+
#feed i { font-style: normal; color: var(--dim); margin: 0 6px; }
|
|
81
|
+
|
|
82
|
+
#tags .tag { position: absolute; left: 0; top: 0; text-align: center; font-size: 10px; font-weight: 800; letter-spacing: 0.08em; color: var(--c); text-shadow: 0 1px 2px #000; }
|
|
83
|
+
#tags .tag > i { display: block; width: 44px; height: 4px; margin: 2px auto 0; background: rgba(0, 0, 0, 0.55); border-radius: 2px; overflow: hidden; }
|
|
84
|
+
#tags .tag > i > i { display: block; height: 100%; background: var(--c); transform-origin: left; }
|
|
85
|
+
|
|
86
|
+
#crosshair { left: 0; top: 0; width: 0; height: 0; }
|
|
87
|
+
#crosshair .ring { position: absolute; left: -13px; top: -13px; width: 26px; height: 26px; border: 2px solid var(--cyan); border-radius: 50%; box-sizing: border-box; opacity: 0.9; }
|
|
88
|
+
#crosshair .ring::after { content: ""; position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px 0 0 -2px; background: var(--pink); border-radius: 50%; }
|
|
89
|
+
#crosshair.fire .ring { border-color: var(--pink); }
|
|
90
|
+
#crosshair .mark { position: absolute; left: 0; top: 0; opacity: 0; }
|
|
91
|
+
#crosshair .mark b { position: absolute; left: -1.5px; top: -5px; width: 3px; height: 10px; border-radius: 1px; background: #fff; box-shadow: 0 0 6px rgba(255, 255, 255, 0.9); }
|
|
92
|
+
#crosshair .mark b:nth-child(1) { transform: rotate(45deg) translateY(-13px); }
|
|
93
|
+
#crosshair .mark b:nth-child(2) { transform: rotate(135deg) translateY(-13px); }
|
|
94
|
+
#crosshair .mark b:nth-child(3) { transform: rotate(225deg) translateY(-13px); }
|
|
95
|
+
#crosshair .mark b:nth-child(4) { transform: rotate(315deg) translateY(-13px); }
|
|
96
|
+
#crosshair.kill .mark b { background: #ff3b5c; box-shadow: 0 0 10px rgba(255, 59, 92, 0.95); height: 14px; top: -7px; }
|
|
97
|
+
|
|
98
|
+
#down { inset: 0; display: none; place-items: center; background: radial-gradient(circle, transparent 35%, rgba(40, 0, 30, 0.55)); }
|
|
99
|
+
#down.show { display: grid; }
|
|
100
|
+
#down .box { text-align: center; }
|
|
101
|
+
#down-title { font-size: 36px; font-weight: 900; letter-spacing: 0.2em; color: var(--pink); }
|
|
102
|
+
#down-sub { color: var(--dim); font-size: 14px; letter-spacing: 0.2em; }
|
|
103
|
+
|
|
104
|
+
#end { inset: 0; display: none; place-items: center; background: rgba(4, 6, 12, 0.74); }
|
|
105
|
+
#end.show { display: grid; }
|
|
106
|
+
#end .box { width: min(420px, 90vw); padding: 22px; border-radius: 14px; background: rgba(10, 14, 26, 0.96); border: 1px solid var(--line); text-align: center; }
|
|
107
|
+
#end-title { font-size: 24px; font-weight: 900; letter-spacing: 0.14em; color: var(--cyan); }
|
|
108
|
+
#end-board { list-style: none; padding: 0; margin: 14px 0; text-align: left; font-size: 14px; }
|
|
109
|
+
#end-board li { display: flex; gap: 8px; padding: 4px 0; border-top: 1px solid rgba(255, 255, 255, 0.06); }
|
|
110
|
+
#end-board li.me { color: var(--cyan); font-weight: 700; }
|
|
111
|
+
#end-board em { font-style: normal; color: var(--dim); width: 16px; }
|
|
112
|
+
#end-board b { margin-left: auto; }
|
|
113
|
+
#end-board small { width: 24px; text-align: right; color: var(--dim); }
|
|
114
|
+
#end-earned { list-style: none; padding: 0; margin: 0 0 12px; text-align: left; font-size: 14px; }
|
|
115
|
+
#end-earned li { display: flex; justify-content: space-between; padding: 6px 0; border-top: 1px solid var(--line); }
|
|
116
|
+
#end-earned b { color: var(--amber); }
|
|
117
|
+
#end-next { color: var(--dim); font-size: 12px; letter-spacing: 0.12em; }
|
|
118
|
+
|
|
119
|
+
#store { top: 0; right: 0; bottom: 0; width: min(330px, 88vw); background: rgba(8, 11, 20, 0.97); border-left: 1px solid var(--line); padding: 18px; box-sizing: border-box; transform: translateX(100%); transition: transform 220ms ease; pointer-events: auto; overflow-y: auto; z-index: 8; }
|
|
120
|
+
#store.show { transform: none; }
|
|
121
|
+
#store h2 { margin: 0 0 2px; font-size: 15px; letter-spacing: 0.2em; }
|
|
122
|
+
#store h3 { margin: 16px 0 0; font-size: 10px; letter-spacing: 0.24em; color: var(--dim); font-weight: 600; }
|
|
123
|
+
#store .item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid rgba(255, 255, 255, 0.06); }
|
|
124
|
+
#store .item > div { display: flex; align-items: center; gap: 10px; }
|
|
125
|
+
#store .swatch { width: 26px; height: 26px; border-radius: 7px; flex: none; }
|
|
126
|
+
#store small { color: var(--dim); }
|
|
127
|
+
#store button { min-width: 88px; }
|
|
128
|
+
#store-close { margin-top: 16px; }
|
|
129
|
+
|
|
130
|
+
.dmg { position: fixed; left: 0; top: 0; z-index: 4; pointer-events: none; font: 800 15px "Avenir Next", system-ui, sans-serif; color: #fff; text-shadow: 0 1px 0 #000, 0 0 8px rgba(255, 79, 216, 0.8); opacity: 0; will-change: transform, opacity; }
|
|
131
|
+
.dmg.big { font-size: 20px; color: var(--pink); }
|
|
132
|
+
|
|
133
|
+
#toast { left: 50%; top: 74px; transform: translate(-50%, -10px); padding: 8px 14px; border-radius: 999px; background: rgba(8, 12, 22, 0.92); border: 1px solid var(--amber); font-weight: 700; opacity: 0; transition: opacity 200ms, transform 200ms; white-space: nowrap; }
|
|
134
|
+
#toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
135
|
+
#hurt { inset: 0; background: radial-gradient(circle, transparent 45%, rgba(255, 40, 120, 0.45)); opacity: 0; }
|
|
136
|
+
#stats { left: 12px; bottom: 90px; font: 11px/1.4 ui-monospace, monospace; color: var(--dim); white-space: pre; display: none; }
|
|
137
|
+
#stats.show { display: block; }
|
|
138
|
+
#help { left: 12px; bottom: max(14px, env(safe-area-inset-bottom)); font-size: 10px; letter-spacing: 0.08em; color: var(--dim); }
|
|
139
|
+
#banner { left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; font-size: 14px; color: var(--dim); letter-spacing: 0.12em; }
|
|
140
|
+
#banner b { display: block; font-size: 44px; letter-spacing: 0.3em; color: var(--cyan); margin-bottom: 8px; text-shadow: 0 0 22px rgba(22, 224, 255, 0.6); }
|
|
141
|
+
|
|
142
|
+
.stick-zone { position: fixed; top: 80px; bottom: 90px; width: 50%; z-index: 3; touch-action: none; }
|
|
143
|
+
.stick-zone.left { left: 0; }
|
|
144
|
+
.stick-zone.right { right: 0; }
|
|
145
|
+
.touch-button { position: fixed; z-index: 6; width: 70px; height: 70px; border-radius: 50%; font-weight: 800; letter-spacing: 0.08em; font-size: 12px; }
|
|
146
|
+
.touch-button.dash { right: 18px; bottom: max(100px, env(safe-area-inset-bottom)); border: 2px solid var(--cyan); background: rgba(22, 224, 255, 0.14); }
|
|
147
|
+
.touch-button.swap { right: 98px; bottom: max(100px, env(safe-area-inset-bottom)); border: 2px solid var(--amber); background: rgba(255, 176, 32, 0.14); }
|
|
148
|
+
/* Framed by Net Forge: the host's own Invite replaces this one. */
|
|
149
|
+
html[data-host="netnet"] #invite { display: none; }
|
|
150
|
+
/* A phone held sideways: Swap and Dash drop to the bottom corner, clear of the
|
|
151
|
+
kill feed (two lines), and stay right of the weapon slots. */
|
|
152
|
+
@media (pointer: coarse) and (max-height: 500px) {
|
|
153
|
+
#feed li:nth-child(n + 3) { display: none; }
|
|
154
|
+
.touch-button.dash, .touch-button.swap { bottom: max(14px, env(safe-area-inset-bottom)); }
|
|
155
|
+
}
|
|
156
|
+
@media (pointer: coarse) {
|
|
157
|
+
#help, #crosshair { display: none; }
|
|
158
|
+
#game { cursor: auto; }
|
|
159
|
+
.slot span { display: none; }
|
|
160
|
+
}
|
|
161
|
+
@media (max-width: 560px) {
|
|
162
|
+
#board li:nth-child(n + 5) { display: none; }
|
|
163
|
+
#timer { font-size: 22px; }
|
|
164
|
+
#goal { display: none; }
|
|
165
|
+
#top-left { width: 34vw; }
|
|
166
|
+
#top-right { top: max(52px, calc(env(safe-area-inset-top) + 42px)); width: 62vw; }
|
|
167
|
+
#top-right button { padding: 5px 8px; font-size: 12px; white-space: nowrap; }
|
|
168
|
+
#board, #feed { font-size: 11px; }
|
|
169
|
+
.slot { width: 30vw; padding: 6px 8px; }
|
|
170
|
+
}
|
|
171
|
+
</style>
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
<div id="game"></div>
|
|
175
|
+
<div id="hurt" class="hud"></div>
|
|
176
|
+
<div id="tags" class="hud" style="inset: 0"></div>
|
|
177
|
+
<div id="arrows" class="hud"></div>
|
|
178
|
+
|
|
179
|
+
<div id="top-left" class="hud">
|
|
180
|
+
<div id="title">VOLTYARD</div>
|
|
181
|
+
<div class="bar"><div id="hp-fill"></div><span id="hp-text"></span></div>
|
|
182
|
+
<div id="buffs"></div>
|
|
183
|
+
</div>
|
|
184
|
+
<div id="top-center" class="hud">
|
|
185
|
+
<div id="timer">3:00</div>
|
|
186
|
+
<div id="goal"></div>
|
|
187
|
+
</div>
|
|
188
|
+
<div id="top-right" class="hud">
|
|
189
|
+
<div id="coin-box"><img id="coin-logo" alt="" width="18" height="18" hidden /><span id="coin-balance"></span></div>
|
|
190
|
+
<div class="row"><button id="invite">Invite</button><button id="store-btn">Store</button><button id="sound">Sound off</button></div>
|
|
191
|
+
<ul id="board"></ul>
|
|
192
|
+
<ul id="feed"></ul>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div id="slots"></div>
|
|
196
|
+
<div id="crosshair" class="hud"><i class="ring"></i><i class="mark"><b></b><b></b><b></b><b></b></i></div>
|
|
197
|
+
|
|
198
|
+
<div id="down" class="hud"><div class="box"><div id="down-title">DOWN</div><div id="down-sub"></div></div></div>
|
|
199
|
+
|
|
200
|
+
<div id="end" class="hud">
|
|
201
|
+
<div class="box">
|
|
202
|
+
<div id="end-title"></div>
|
|
203
|
+
<ul id="end-board"></ul>
|
|
204
|
+
<ul id="end-earned"></ul>
|
|
205
|
+
<div id="end-next"></div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<aside id="store" class="hud">
|
|
210
|
+
<h2>STORE</h2>
|
|
211
|
+
<small id="coin-name"></small>
|
|
212
|
+
<h3>THIS ROUND</h3>
|
|
213
|
+
<div id="store-upgrades"></div>
|
|
214
|
+
<h3>FINISHES</h3>
|
|
215
|
+
<div id="store-finishes"></div>
|
|
216
|
+
<div class="item" id="gear-row"><div style="display: block"><span id="gear-name"></span><br /><small id="gear-line"></small></div><button id="gear-buy" style="white-space: nowrap"></button></div>
|
|
217
|
+
<div class="item" id="pot-row"><div style="display: block">Round pot<br /><small id="pot-line"></small></div><button id="pot-go" style="white-space: nowrap"></button></div>
|
|
218
|
+
<div class="item" id="stake-row"><div style="display: block">Stake a run<br /><small id="stake-line"></small></div><span style="white-space: nowrap"><input id="stake-amount" type="number" aria-label="Stake" style="width: 56px" /> <button id="stake-go" style="min-width: 0">Stake</button></span></div>
|
|
219
|
+
<small id="store-note"></small>
|
|
220
|
+
<br /><button id="store-close">Close</button>
|
|
221
|
+
</aside>
|
|
222
|
+
|
|
223
|
+
<ul id="board-full" class="hud"></ul>
|
|
224
|
+
<div id="toast" class="hud"></div>
|
|
225
|
+
<div id="stats" class="hud"></div>
|
|
226
|
+
<div id="help" class="hud">WASD move · mouse aim · click fire · 1 2 3 or Q weapon · Space dash · Tab board</div>
|
|
227
|
+
<div id="banner" class="hud"><b>VOLTYARD</b><span id="banner-text">Charging the yard</span></div>
|
|
228
|
+
<script type="module" src="./main.ts"></script>
|
|
229
|
+
</body>
|
|
230
|
+
</html>
|
package/client/input.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twin-stick input for VOLTYARD.
|
|
3
|
+
*
|
|
4
|
+
* - Keyboard and mouse: WASD or arrows move, the mouse aims, hold the left
|
|
5
|
+
* button to fire, 1 2 3 pick a weapon, Q or the wheel cycles, Space or
|
|
6
|
+
* Shift dashes.
|
|
7
|
+
* - Gamepad: left stick moves, right stick aims (pushing it far fires),
|
|
8
|
+
* RT fires, LB or RB cycles, A dashes. Rumble on hits.
|
|
9
|
+
* - Touch: a stick on the left half moves, a stick on the right half aims
|
|
10
|
+
* and fires while pushed; SWAP and DASH buttons.
|
|
11
|
+
*
|
|
12
|
+
* The sim's frame: moveX is +x (screen right), moveZ is -z (screen up),
|
|
13
|
+
* yaw is the aim (0 aims up the screen). Presses are held two ticks so the
|
|
14
|
+
* sim sees the edge even if a tick is skipped.
|
|
15
|
+
*/
|
|
16
|
+
import { Button } from "@netforge/engine-sim";
|
|
17
|
+
import type { InputCommand } from "@netforge/engine-sim";
|
|
18
|
+
|
|
19
|
+
/** Screen point (CSS px) to an aim yaw from our character, or null before the first frame. */
|
|
20
|
+
export type AimFrom = (clientX: number, clientY: number) => number | null;
|
|
21
|
+
|
|
22
|
+
export class ArenaInput {
|
|
23
|
+
private readonly keys = new Set<string>();
|
|
24
|
+
private mouseX = -1;
|
|
25
|
+
private mouseY = -1;
|
|
26
|
+
private mouseDown = false;
|
|
27
|
+
private yaw = 0;
|
|
28
|
+
private moveStick = { x: 0, y: 0 };
|
|
29
|
+
private aimStick = { x: 0, y: 0, on: false };
|
|
30
|
+
private dashHeld = false;
|
|
31
|
+
private pressBits = 0;
|
|
32
|
+
private pressTicks = 0;
|
|
33
|
+
private readonly cleanup: (() => void)[] = [];
|
|
34
|
+
readonly touch: boolean;
|
|
35
|
+
private pad: Gamepad | null = null;
|
|
36
|
+
private padPrev = 0;
|
|
37
|
+
/** Mouse aim is live (the cursor moved over the game). */
|
|
38
|
+
private mouseAim = false;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly root: HTMLElement,
|
|
42
|
+
private readonly aimFrom: AimFrom,
|
|
43
|
+
) {
|
|
44
|
+
this.touch = globalThis.matchMedia?.("(pointer: coarse)").matches || "ontouchstart" in globalThis;
|
|
45
|
+
const down = (e: KeyboardEvent) => {
|
|
46
|
+
if (e.code === "Space" || e.code.startsWith("Arrow")) e.preventDefault();
|
|
47
|
+
if (e.repeat) return;
|
|
48
|
+
this.keys.add(e.code);
|
|
49
|
+
if (e.code === "Digit1" || e.code === "Numpad1") this.press(Button.Choice1);
|
|
50
|
+
if (e.code === "Digit2" || e.code === "Numpad2") this.press(Button.Choice2);
|
|
51
|
+
if (e.code === "Digit3" || e.code === "Numpad3") this.press(Button.Choice3);
|
|
52
|
+
if (e.code === "KeyQ" || e.code === "KeyE") this.press(Button.Alt);
|
|
53
|
+
};
|
|
54
|
+
const up = (e: KeyboardEvent) => this.keys.delete(e.code);
|
|
55
|
+
const blur = () => {
|
|
56
|
+
this.keys.clear();
|
|
57
|
+
this.mouseDown = false;
|
|
58
|
+
};
|
|
59
|
+
const move = (e: PointerEvent) => {
|
|
60
|
+
if (e.pointerType !== "mouse") return;
|
|
61
|
+
this.mouseX = e.clientX;
|
|
62
|
+
this.mouseY = e.clientY;
|
|
63
|
+
this.mouseAim = true;
|
|
64
|
+
};
|
|
65
|
+
const pdown = (e: PointerEvent) => {
|
|
66
|
+
if (e.pointerType !== "mouse" || e.button !== 0) return;
|
|
67
|
+
if ((e.target as HTMLElement | null)?.closest?.("button, aside, .panel")) return;
|
|
68
|
+
this.mouseDown = true;
|
|
69
|
+
this.mouseX = e.clientX;
|
|
70
|
+
this.mouseY = e.clientY;
|
|
71
|
+
this.mouseAim = true;
|
|
72
|
+
};
|
|
73
|
+
const pup = (e: PointerEvent) => {
|
|
74
|
+
if (e.pointerType === "mouse" && e.button === 0) this.mouseDown = false;
|
|
75
|
+
};
|
|
76
|
+
const wheel = (e: WheelEvent) => {
|
|
77
|
+
if (Math.abs(e.deltaY) > 4) this.press(Button.Alt);
|
|
78
|
+
};
|
|
79
|
+
const menu = (e: Event) => e.preventDefault();
|
|
80
|
+
addEventListener("keydown", down);
|
|
81
|
+
addEventListener("keyup", up);
|
|
82
|
+
addEventListener("blur", blur);
|
|
83
|
+
addEventListener("pointermove", move);
|
|
84
|
+
addEventListener("pointerdown", pdown);
|
|
85
|
+
addEventListener("pointerup", pup);
|
|
86
|
+
addEventListener("wheel", wheel, { passive: true });
|
|
87
|
+
root.addEventListener("contextmenu", menu);
|
|
88
|
+
this.cleanup.push(() => {
|
|
89
|
+
removeEventListener("keydown", down);
|
|
90
|
+
removeEventListener("keyup", up);
|
|
91
|
+
removeEventListener("blur", blur);
|
|
92
|
+
removeEventListener("pointermove", move);
|
|
93
|
+
removeEventListener("pointerdown", pdown);
|
|
94
|
+
removeEventListener("pointerup", pup);
|
|
95
|
+
removeEventListener("wheel", wheel);
|
|
96
|
+
root.removeEventListener("contextmenu", menu);
|
|
97
|
+
});
|
|
98
|
+
if (this.touch) void this.bindTouch();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Press a button for the next two ticks (weapon picks from keys, the HUD and pads). */
|
|
102
|
+
press(bits: number): void {
|
|
103
|
+
this.pressBits |= bits;
|
|
104
|
+
this.pressTicks = 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is the player firing right now (for the crosshair). */
|
|
108
|
+
get firing(): boolean {
|
|
109
|
+
return this.mouseDown || (this.aimStick.on && Math.hypot(this.aimStick.x, this.aimStick.y) > 0.5);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async bindTouch(): Promise<void> {
|
|
113
|
+
const nipple = await import("nipplejs");
|
|
114
|
+
const left = document.createElement("div");
|
|
115
|
+
left.className = "stick-zone left";
|
|
116
|
+
const right = document.createElement("div");
|
|
117
|
+
right.className = "stick-zone right";
|
|
118
|
+
this.root.append(left, right);
|
|
119
|
+
const mover = nipple.create({ zone: left, mode: "dynamic", color: "rgba(22,224,255,0.7)", size: 120 });
|
|
120
|
+
mover.on("move", (evt) => {
|
|
121
|
+
this.moveStick = { x: evt.data.vector.x, y: evt.data.vector.y };
|
|
122
|
+
});
|
|
123
|
+
mover.on("end", () => {
|
|
124
|
+
this.moveStick = { x: 0, y: 0 };
|
|
125
|
+
});
|
|
126
|
+
const aimer = nipple.create({ zone: right, mode: "dynamic", color: "rgba(255,79,216,0.7)", size: 120 });
|
|
127
|
+
aimer.on("move", (evt) => {
|
|
128
|
+
this.aimStick = { x: evt.data.vector.x, y: evt.data.vector.y, on: true };
|
|
129
|
+
this.mouseAim = false;
|
|
130
|
+
});
|
|
131
|
+
aimer.on("end", () => {
|
|
132
|
+
this.aimStick = { x: 0, y: 0, on: false };
|
|
133
|
+
});
|
|
134
|
+
const button = (label: string, cls: string, down: () => void, upFn?: () => void) => {
|
|
135
|
+
const b = document.createElement("button");
|
|
136
|
+
b.className = `touch-button ${cls}`;
|
|
137
|
+
b.textContent = label;
|
|
138
|
+
b.addEventListener("pointerdown", (e) => {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
down();
|
|
142
|
+
});
|
|
143
|
+
if (upFn) for (const ev of ["pointerup", "pointercancel", "pointerleave"]) b.addEventListener(ev, upFn);
|
|
144
|
+
this.root.append(b);
|
|
145
|
+
return b;
|
|
146
|
+
};
|
|
147
|
+
const dash = button("DASH", "dash", () => (this.dashHeld = true), () => (this.dashHeld = false));
|
|
148
|
+
const swap = button("SWAP", "swap", () => this.press(Button.Alt));
|
|
149
|
+
this.cleanup.push(() => {
|
|
150
|
+
mover.destroy();
|
|
151
|
+
aimer.destroy();
|
|
152
|
+
left.remove();
|
|
153
|
+
right.remove();
|
|
154
|
+
dash.remove();
|
|
155
|
+
swap.remove();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private readPad(): { x: number; y: number; aimX: number; aimY: number; buttons: number } | null {
|
|
160
|
+
const pads = navigator.getGamepads?.() ?? [];
|
|
161
|
+
this.pad = null;
|
|
162
|
+
for (const p of pads) if (p && p.connected) this.pad = p;
|
|
163
|
+
const p = this.pad;
|
|
164
|
+
if (!p) return null;
|
|
165
|
+
const dz = (v: number) => (Math.abs(v) < 0.2 ? 0 : v);
|
|
166
|
+
const held = (i: number) => (p.buttons[i]?.pressed ? 1 << i : 0);
|
|
167
|
+
const now = held(0) | held(4) | held(5) | held(7);
|
|
168
|
+
const pressed = now & ~this.padPrev;
|
|
169
|
+
this.padPrev = now;
|
|
170
|
+
let buttons = 0;
|
|
171
|
+
if (now & 1) buttons |= Button.Sprint;
|
|
172
|
+
if (now & (1 << 7)) buttons |= Button.Fire;
|
|
173
|
+
if (pressed & ((1 << 4) | (1 << 5))) this.press(Button.Alt);
|
|
174
|
+
return { x: dz(p.axes[0] ?? 0), y: dz(p.axes[1] ?? 0), aimX: dz(p.axes[2] ?? 0), aimY: dz(p.axes[3] ?? 0), buttons };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Controller rumble (Gamepad API), when a pad that supports it is connected. */
|
|
178
|
+
rumble(strength: number, ms: number): void {
|
|
179
|
+
const act = (this.pad as (Gamepad & { vibrationActuator?: { playEffect?: (t: string, p: object) => Promise<unknown> } }) | null)?.vibrationActuator;
|
|
180
|
+
void act?.playEffect?.("dual-rumble", { duration: ms, strongMagnitude: strength, weakMagnitude: strength * 0.6 }).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The current aim, radians (0 aims up the screen). */
|
|
184
|
+
get aim(): number {
|
|
185
|
+
return this.yaw;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One command for the next fixed tick. */
|
|
189
|
+
sample(tick: number): InputCommand {
|
|
190
|
+
const k = this.keys;
|
|
191
|
+
let mx = (k.has("KeyD") || k.has("ArrowRight") ? 1 : 0) - (k.has("KeyA") || k.has("ArrowLeft") ? 1 : 0);
|
|
192
|
+
let my = (k.has("KeyW") || k.has("ArrowUp") ? 1 : 0) - (k.has("KeyS") || k.has("ArrowDown") ? 1 : 0);
|
|
193
|
+
let buttons = 0;
|
|
194
|
+
if (k.has("Space") || k.has("ShiftLeft") || k.has("ShiftRight") || this.dashHeld) buttons |= Button.Sprint;
|
|
195
|
+
if (this.mouseDown) buttons |= Button.Fire;
|
|
196
|
+
if (this.mouseAim && this.mouseX >= 0) {
|
|
197
|
+
const y = this.aimFrom(this.mouseX, this.mouseY);
|
|
198
|
+
if (y !== null) this.yaw = y;
|
|
199
|
+
}
|
|
200
|
+
const pad = this.readPad();
|
|
201
|
+
if (pad) {
|
|
202
|
+
if (pad.x !== 0 || pad.y !== 0) {
|
|
203
|
+
mx = pad.x;
|
|
204
|
+
my = -pad.y;
|
|
205
|
+
}
|
|
206
|
+
if (pad.aimX !== 0 || pad.aimY !== 0) {
|
|
207
|
+
// Stick down is +z on screen.
|
|
208
|
+
this.yaw = Math.atan2(-pad.aimX, -pad.aimY);
|
|
209
|
+
this.mouseAim = false;
|
|
210
|
+
if (Math.hypot(pad.aimX, pad.aimY) > 0.85) buttons |= Button.Fire;
|
|
211
|
+
}
|
|
212
|
+
buttons |= pad.buttons;
|
|
213
|
+
}
|
|
214
|
+
if (this.moveStick.x !== 0 || this.moveStick.y !== 0) {
|
|
215
|
+
mx = this.moveStick.x;
|
|
216
|
+
my = this.moveStick.y;
|
|
217
|
+
}
|
|
218
|
+
if (this.aimStick.on && (this.aimStick.x !== 0 || this.aimStick.y !== 0)) {
|
|
219
|
+
// nipplejs: y is up the screen, which is -z.
|
|
220
|
+
this.yaw = Math.atan2(-this.aimStick.x, this.aimStick.y);
|
|
221
|
+
if (Math.hypot(this.aimStick.x, this.aimStick.y) > 0.5) buttons |= Button.Fire;
|
|
222
|
+
}
|
|
223
|
+
if (this.pressTicks > 0) {
|
|
224
|
+
buttons |= this.pressBits;
|
|
225
|
+
if (--this.pressTicks === 0) this.pressBits = 0;
|
|
226
|
+
}
|
|
227
|
+
return { seq: 0, tick, moveX: mx, moveZ: my, yaw: this.yaw, buttons };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
dispose(): void {
|
|
231
|
+
for (const f of this.cleanup.splice(0)) f();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fallback shapes. Every library id the game
|
|
3
|
+
* references is drawn from the per-style asset library
|
|
4
|
+
* (@netforge/asset-library: real CC0 models, see scene.ts); these
|
|
5
|
+
* primitives are what the scene shows until a model loads, and what it
|
|
6
|
+
* keeps when the library is not built or not reachable, so the game always
|
|
7
|
+
* draws. The keys are the template's original ids, which the library keeps
|
|
8
|
+
* as aliases.
|
|
9
|
+
*/
|
|
10
|
+
import { assetById } from "@netforge/asset-library";
|
|
11
|
+
import {
|
|
12
|
+
BoxGeometry,
|
|
13
|
+
BufferAttribute,
|
|
14
|
+
BufferGeometry,
|
|
15
|
+
CapsuleGeometry,
|
|
16
|
+
Color,
|
|
17
|
+
ConeGeometry,
|
|
18
|
+
CylinderGeometry,
|
|
19
|
+
IcosahedronGeometry,
|
|
20
|
+
Material,
|
|
21
|
+
MeshStandardMaterial,
|
|
22
|
+
OctahedronGeometry,
|
|
23
|
+
TetrahedronGeometry,
|
|
24
|
+
} from "three/webgpu";
|
|
25
|
+
|
|
26
|
+
/** Concatenate geometries (positions and normals) into one draw. */
|
|
27
|
+
export function merge(parts: readonly BufferGeometry[]): BufferGeometry {
|
|
28
|
+
const flat = parts.map((p) => (p.index ? p.toNonIndexed() : p));
|
|
29
|
+
let n = 0;
|
|
30
|
+
for (const p of flat) n += p.getAttribute("position").count;
|
|
31
|
+
const pos = new Float32Array(n * 3);
|
|
32
|
+
const nor = new Float32Array(n * 3);
|
|
33
|
+
let o = 0;
|
|
34
|
+
for (const p of flat) {
|
|
35
|
+
p.computeVertexNormals();
|
|
36
|
+
const a = p.getAttribute("position");
|
|
37
|
+
const b = p.getAttribute("normal");
|
|
38
|
+
for (let i = 0; i < a.count; i++) {
|
|
39
|
+
pos[(o + i) * 3] = a.getX(i);
|
|
40
|
+
pos[(o + i) * 3 + 1] = a.getY(i);
|
|
41
|
+
pos[(o + i) * 3 + 2] = a.getZ(i);
|
|
42
|
+
nor[(o + i) * 3] = b.getX(i);
|
|
43
|
+
nor[(o + i) * 3 + 1] = b.getY(i);
|
|
44
|
+
nor[(o + i) * 3 + 2] = b.getZ(i);
|
|
45
|
+
}
|
|
46
|
+
o += a.count;
|
|
47
|
+
}
|
|
48
|
+
const g = new BufferGeometry();
|
|
49
|
+
g.setAttribute("position", new BufferAttribute(pos, 3));
|
|
50
|
+
g.setAttribute("normal", new BufferAttribute(nor, 3));
|
|
51
|
+
return g;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LibraryAsset {
|
|
55
|
+
readonly geometry: BufferGeometry;
|
|
56
|
+
readonly material: Material;
|
|
57
|
+
/** Height of the model's origin above the floor, m. */
|
|
58
|
+
readonly lift: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mat = (color: number, emissive = 0x000000, emissiveIntensity = 1, roughness = 0.7, metalness = 0.1) =>
|
|
62
|
+
new MeshStandardMaterial({ color: new Color(color), emissive: new Color(emissive), emissiveIntensity, roughness, metalness, flatShading: true });
|
|
63
|
+
|
|
64
|
+
/** A figure facing -z: torso capsule, a visor, shoulder pads and a gun barrel forward. */
|
|
65
|
+
function figure(scale: number, bulk: number): BufferGeometry {
|
|
66
|
+
const torso = new CapsuleGeometry(0.36 * bulk, 0.7, 4, 8);
|
|
67
|
+
torso.translate(0, 0.75, 0);
|
|
68
|
+
const visor = new BoxGeometry(0.42, 0.14, 0.2);
|
|
69
|
+
visor.translate(0, 1.25, -0.22);
|
|
70
|
+
const padL = new BoxGeometry(0.22, 0.16, 0.3);
|
|
71
|
+
padL.translate(-0.38 * bulk, 1.05, 0);
|
|
72
|
+
const padR = padL.clone();
|
|
73
|
+
padR.translate(0.76 * bulk, 0, 0);
|
|
74
|
+
const barrel = new CylinderGeometry(0.06, 0.08, 0.85, 6);
|
|
75
|
+
barrel.rotateX(Math.PI / 2);
|
|
76
|
+
barrel.translate(0.22, 0.85, -0.55);
|
|
77
|
+
const pack = new BoxGeometry(0.4, 0.5, 0.2);
|
|
78
|
+
pack.translate(0, 0.9, 0.32);
|
|
79
|
+
const g = merge([torso, visor, padL, padR, barrel, pack]);
|
|
80
|
+
g.scale(scale, scale, scale);
|
|
81
|
+
return g;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const builders: Record<string, (radius: number) => LibraryAsset> = {
|
|
85
|
+
wirejack: () => ({ geometry: figure(1, 1), material: mat(0xffffff, 0x000000, 1, 0.45, 0.35), lift: 0 }),
|
|
86
|
+
bruiser: () => ({ geometry: figure(1.08, 1.25), material: mat(0xffffff, 0x000000, 1, 0.5, 0.4), lift: 0 }),
|
|
87
|
+
volt_cell: () => {
|
|
88
|
+
const g = new OctahedronGeometry(0.3);
|
|
89
|
+
g.scale(1, 1.5, 1);
|
|
90
|
+
return { geometry: g, material: mat(0x7dff9a, 0x2bff6a, 1.8, 0.25), lift: 0.6 };
|
|
91
|
+
},
|
|
92
|
+
spark_cell: () => {
|
|
93
|
+
const g = new IcosahedronGeometry(0.22);
|
|
94
|
+
return { geometry: g, material: mat(0x7ad7ff, 0x1e9bff, 1.8, 0.25), lift: 0.5 };
|
|
95
|
+
},
|
|
96
|
+
drone: (r) => {
|
|
97
|
+
const g = new ConeGeometry(r * 1.1, r * 1.6, 4);
|
|
98
|
+
g.rotateX(Math.PI / 2);
|
|
99
|
+
g.translate(0, r * 1.4, 0);
|
|
100
|
+
return { geometry: g, material: mat(0xff5a4f, 0x7a1208, 1, 0.5), lift: 0 };
|
|
101
|
+
},
|
|
102
|
+
scrap_mite: (r) => {
|
|
103
|
+
const g = new TetrahedronGeometry(r * 1.3);
|
|
104
|
+
g.translate(0, r * 1.2, 0);
|
|
105
|
+
return { geometry: g, material: mat(0xffb020, 0x6a3a00, 0.9, 0.55), lift: 0 };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Resolve a library id ("style.lowpoly.mini_male_f") to its fallback shape for an entity of `radius` m. Unknown ids get a marked box. */
|
|
110
|
+
export function resolveAsset(id: string, radius = 0.35): LibraryAsset {
|
|
111
|
+
const name = id.split(".")[2] ?? "";
|
|
112
|
+
const b = builders[name];
|
|
113
|
+
if (b) return b(radius);
|
|
114
|
+
// A library id: its alias's builder, else a figure for characters.
|
|
115
|
+
const a = assetById(id);
|
|
116
|
+
for (const alias of a?.aliases ?? []) {
|
|
117
|
+
const ab = builders[alias.split(".")[2] ?? ""];
|
|
118
|
+
if (ab) return ab(radius);
|
|
119
|
+
}
|
|
120
|
+
if (a?.kind === "character") return builders.wirejack?.(radius) ?? { geometry: figure(1, 1), material: mat(0xffffff), lift: 0 };
|
|
121
|
+
const g = new BoxGeometry(radius * 2, radius * 2, radius * 2);
|
|
122
|
+
g.translate(0, radius, 0);
|
|
123
|
+
return { geometry: g, material: mat(0xff00ff, 0x400040), lift: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Projectile, beam and effect meshes (template machinery, not library assets). */
|
|
127
|
+
export const fx = {
|
|
128
|
+
/** A rifle bolt: a thin streak along -z. */
|
|
129
|
+
bolt: () => {
|
|
130
|
+
const g = new BoxGeometry(0.08, 0.08, 1.1);
|
|
131
|
+
return { geometry: g, material: mat(0x9ff6ff, 0x16e0ff, 3, 0.2) };
|
|
132
|
+
},
|
|
133
|
+
/** A scatter pellet. */
|
|
134
|
+
pellet: () => {
|
|
135
|
+
const g = new OctahedronGeometry(0.11);
|
|
136
|
+
g.scale(1, 1, 2);
|
|
137
|
+
return { geometry: g, material: mat(0xffd27a, 0xff8a00, 3, 0.2) };
|
|
138
|
+
},
|
|
139
|
+
/** A beam: a unit-long bar along -z, scaled to the beam's reach. */
|
|
140
|
+
beam: () => {
|
|
141
|
+
const g = new BoxGeometry(0.12, 0.12, 1);
|
|
142
|
+
g.translate(0, 0, -0.5);
|
|
143
|
+
return g;
|
|
144
|
+
},
|
|
145
|
+
shard: () => new TetrahedronGeometry(0.1),
|
|
146
|
+
};
|