| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
import { choosePrompt, REACH } from "../static/prompt.js"; |
| 11 |
|
| 12 |
const base = |
| 13 |
process.env.VAL_URL ?? |
| 14 |
(process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? |
| 15 |
process.env.FORGE_APP_URL ?? |
| 16 |
"http://127.0.0.1:8080"; |
| 17 |
|
| 18 |
const esc = "\x1b["; |
| 19 |
const out = (s: string): void => void process.stdout.write(s); |
| 20 |
|
| 21 |
type Snapshot = { |
| 22 |
phase: string; |
| 23 |
clock: number; |
| 24 |
round: number; |
| 25 |
score: { red: number; blue: number }; |
| 26 |
attackers: string; |
| 27 |
result: string | null; |
| 28 |
me: Record<string, any>; |
| 29 |
players: Record<string, any>[]; |
| 30 |
zones: { k: string; x: number; y: number; r: number }[]; |
| 31 |
spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null }; |
| 32 |
board: Record<string, any>[]; |
| 33 |
events: string[]; |
| 34 |
}; |
| 35 |
|
| 36 |
let map: { rows: string[]; tile: number } | null = null; |
| 37 |
let snap: Snapshot | null = null; |
| 38 |
let socket: WebSocket | null = null; |
| 39 |
let status = "connecting"; |
| 40 |
let aim = -Math.PI / 2; |
| 41 |
let move = { mx: 0, my: 0, until: 0 }; |
| 42 |
let firing = 0; |
| 43 |
let helping = false; |
| 44 |
let using = 0; |
| 45 |
let buying = false; |
| 46 |
|
| 47 |
const send = (message: unknown): void => { |
| 48 |
if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); |
| 49 |
}; |
| 50 |
|
| 51 |
async function connect(): Promise<void> { |
| 52 |
try { |
| 53 |
const page = await fetch(base + "/"); |
| 54 |
const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 55 |
map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number }; |
| 56 |
const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", { |
| 57 |
headers: { cookie, origin: new URL(base).origin }, |
| 58 |
} as unknown as string[]); |
| 59 |
socket = ws; |
| 60 |
ws.onopen = () => (status = "live"); |
| 61 |
ws.onclose = () => { |
| 62 |
status = "reconnecting"; |
| 63 |
socket = null; |
| 64 |
setTimeout(connect, 2000); |
| 65 |
}; |
| 66 |
ws.onmessage = (event) => { |
| 67 |
const next = JSON.parse(String(event.data)) as Snapshot; |
| 68 |
|
| 69 |
if (next.board === undefined) next.board = snap?.board ?? []; |
| 70 |
snap = next; |
| 71 |
}; |
| 72 |
} catch (error) { |
| 73 |
status = `cannot reach ${base}`; |
| 74 |
setTimeout(connect, 3000); |
| 75 |
} |
| 76 |
} |
| 77 |
|
| 78 |
const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; |
| 79 |
|
| 80 |
|
| 81 |
function prompt(s: Snapshot): string { |
| 82 |
const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : ""; |
| 83 |
const cell = row ? row[Math.floor(s.me.x / map.tile)] : ""; |
| 84 |
return choosePrompt(s, { |
| 85 |
input: "keys", |
| 86 |
onSite: cell === "A" || cell === "B", |
| 87 |
nearSpike: |
| 88 |
s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, |
| 89 |
}); |
| 90 |
} |
| 91 |
|
| 92 |
const HOW_TO = [ |
| 93 |
"How to play — attackers carry the spike to site A or B and plant it (hold f);", |
| 94 |
"defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.", |
| 95 |
"· wasd walks, ←/→ turns the aim, space fires, r reloads", |
| 96 |
"· c q e x cast the kit: c and q are bought in the buy phase, e is free on a cooldown, x is the ultimate", |
| 97 |
"· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them", |
| 98 |
"· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see", |
| 99 |
"h closes this.", |
| 100 |
]; |
| 101 |
|
| 102 |
function draw(): void { |
| 103 |
if (!map) { |
| 104 |
out(`${esc}2J${esc}Hval — ${status}\r\n`); |
| 105 |
return; |
| 106 |
} |
| 107 |
const rows = map.rows.map((row) => row.split("").map((c) => (c === "#" ? colour("▓", 90) : c === "A" || c === "B" ? colour(c, 33) : c === "a" || c === "d" ? colour("·", 90) : " "))); |
| 108 |
const put = (x: number, y: number, ch: string): void => { |
| 109 |
const col = Math.floor(x / map!.tile); |
| 110 |
const row = Math.floor(y / map!.tile); |
| 111 |
if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch; |
| 112 |
}; |
| 113 |
|
| 114 |
const lines: string[] = []; |
| 115 |
if (snap) { |
| 116 |
for (const zone of snap.zones) put(zone.x, zone.y, colour(zone.k === "smoke" ? "▒" : zone.k === "molly" ? "≈" : zone.k === "wall" ? "█" : "*", zone.k === "molly" ? 31 : 37)); |
| 117 |
if (snap.spike.x !== null && snap.spike.y !== null) |
| 118 |
put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33)); |
| 119 |
for (const player of snap.players) { |
| 120 |
if (!player.alive) continue; |
| 121 |
const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31); |
| 122 |
put(player.x, player.y, mark); |
| 123 |
} |
| 124 |
const me = snap.me; |
| 125 |
const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase; |
| 126 |
lines.push( |
| 127 |
`${colour("val", 1)} ${colour("red " + snap.score.red, 31)} — ${colour(snap.score.blue + " blue", 36)} round ${snap.round} ${phase} ${Math.ceil(snap.clock)}s ${snap.attackers} attacking [${status}]`, |
| 128 |
); |
| 129 |
lines.push( |
| 130 |
`${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`, |
| 131 |
); |
| 132 |
lines.push(colour("› " + prompt(snap), 93)); |
| 133 |
} else lines.push(`val — ${status}`); |
| 134 |
lines.push(""); |
| 135 |
for (const row of rows) lines.push(" " + row.join("")); |
| 136 |
lines.push(""); |
| 137 |
if (snap) { |
| 138 |
for (const side of ["atk", "def"] as const) { |
| 139 |
const team = snap.board |
| 140 |
.filter((p) => p.side === side) |
| 141 |
.map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`) |
| 142 |
.join(" "); |
| 143 |
lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team); |
| 144 |
} |
| 145 |
lines.push(""); |
| 146 |
for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); |
| 147 |
} |
| 148 |
lines.push(""); |
| 149 |
if (helping) for (const line of HOW_TO) lines.push(colour(line, 96)); |
| 150 |
lines.push( |
| 151 |
buying |
| 152 |
? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33) |
| 153 |
: colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · h how to play · q quit", 90), |
| 154 |
); |
| 155 |
out(`${esc}H${esc}J` + lines.join("\r\n")); |
| 156 |
} |
| 157 |
|
| 158 |
const BUYS: Record<string, string> = { |
| 159 |
"1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator", |
| 160 |
"8": "light", "9": "heavy", |
| 161 |
}; |
| 162 |
|
| 163 |
process.stdin.setRawMode?.(true); |
| 164 |
process.stdin.resume(); |
| 165 |
process.stdin.setEncoding("utf8"); |
| 166 |
process.stdin.on("data", (key: string) => { |
| 167 |
const now = Date.now(); |
| 168 |
if (key === "\x03" || (key === "q" && !buying)) { |
| 169 |
out(`${esc}2J${esc}H`); |
| 170 |
process.exit(0); |
| 171 |
} |
| 172 |
if (buying) { |
| 173 |
if (BUYS[key]) send({ t: "buy", what: BUYS[key] }); |
| 174 |
else if (key === "c" || key === "q") send({ t: "buy", what: key }); |
| 175 |
else if (key === "b" || key === "\x1b") buying = false; |
| 176 |
return; |
| 177 |
} |
| 178 |
if (key === "b") return void (buying = true); |
| 179 |
if (key === "h" || key === "?") return void (helping = !helping); |
| 180 |
if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 181 |
if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 182 |
if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; |
| 183 |
if (key === "d") move = { mx: 1, my: move.my, until: now + 400 }; |
| 184 |
if (key === "\x1b[D") aim -= Math.PI / 12; |
| 185 |
if (key === "\x1b[C") aim += Math.PI / 12; |
| 186 |
if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 187 |
if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 188 |
if (key === " ") firing = now + 220; |
| 189 |
if (key === "f") using = now + 700; |
| 190 |
if (key === "r") send({ t: "i", r: true }); |
| 191 |
if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key }); |
| 192 |
}); |
| 193 |
|
| 194 |
|
| 195 |
setInterval(() => { |
| 196 |
const now = Date.now(); |
| 197 |
if (now > move.until) move = { mx: 0, my: 0, until: 0 }; |
| 198 |
send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false }); |
| 199 |
}, 50); |
| 200 |
setInterval(draw, 120); |
| 201 |
process.stdout.on("resize", draw); |
| 202 |
|
| 203 |
out(`${esc}2J`); |
| 204 |
draw(); |
| 205 |
void connect(); |