quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 1ccff5068bac93bb3a5ec6c40e2e08a7b0291040
| 1 | /** |
| 2 | * val, in a terminal. The forge runs this on a pty when somebody ssh-es to |
| 3 | * the app; it takes a seat over the same socket the browser uses, so what a |
| 4 | * person can do on the page they can do here: walk, shoot, cast, plant, |
| 5 | * defuse and buy. |
| 6 | * |
| 7 | * The map is the same grid, one character a tile. Plain ANSI, no dependencies. |
| 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 | snap = JSON.parse(String(event.data)) as Snapshot; |
| 68 | }; |
| 69 | } catch (error) { |
| 70 | status = `cannot reach ${base}`; |
| 71 | setTimeout(connect, 3000); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; |
| 76 | |
| 77 | /** The same line the page shows, from the same function. */ |
| 78 | function prompt(s: Snapshot): string { |
| 79 | const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : ""; |
| 80 | const cell = row ? row[Math.floor(s.me.x / map.tile)] : ""; |
| 81 | return choosePrompt(s, { |
| 82 | input: "keys", |
| 83 | onSite: cell === "A" || cell === "B", |
| 84 | nearSpike: |
| 85 | s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, |
| 86 | }); |
| 87 | } |
| 88 | |
| 89 | const HOW_TO = [ |
| 90 | "How to play — attackers carry the spike to site A or B and plant it (hold f);", |
| 91 | "defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.", |
| 92 | "· wasd walks, ←/→ turns the aim, space fires, r reloads", |
| 93 | "· 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", |
| 94 | "· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them", |
| 95 | "· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see", |
| 96 | "h closes this.", |
| 97 | ]; |
| 98 | |
| 99 | function draw(): void { |
| 100 | if (!map) { |
| 101 | out(`${esc}2J${esc}Hval — ${status}\r\n`); |
| 102 | return; |
| 103 | } |
| 104 | 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) : " "))); |
| 105 | const put = (x: number, y: number, ch: string): void => { |
| 106 | const col = Math.floor(x / map!.tile); |
| 107 | const row = Math.floor(y / map!.tile); |
| 108 | if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch; |
| 109 | }; |
| 110 | |
| 111 | const lines: string[] = []; |
| 112 | if (snap) { |
| 113 | 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)); |
| 114 | if (snap.spike.x !== null && snap.spike.y !== null) |
| 115 | put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33)); |
| 116 | for (const player of snap.players) { |
| 117 | if (!player.alive) continue; |
| 118 | const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31); |
| 119 | put(player.x, player.y, mark); |
| 120 | } |
| 121 | const me = snap.me; |
| 122 | const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase; |
| 123 | lines.push( |
| 124 | `${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}]`, |
| 125 | ); |
| 126 | lines.push( |
| 127 | `${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)}°`, |
| 128 | ); |
| 129 | lines.push(colour("› " + prompt(snap), 93)); |
| 130 | } else lines.push(`val — ${status}`); |
| 131 | lines.push(""); |
| 132 | for (const row of rows) lines.push(" " + row.join("")); |
| 133 | lines.push(""); |
| 134 | if (snap) { |
| 135 | for (const side of ["atk", "def"] as const) { |
| 136 | const team = snap.board |
| 137 | .filter((p) => p.side === side) |
| 138 | .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`) |
| 139 | .join(" "); |
| 140 | lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team); |
| 141 | } |
| 142 | lines.push(""); |
| 143 | for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); |
| 144 | } |
| 145 | lines.push(""); |
| 146 | if (helping) for (const line of HOW_TO) lines.push(colour(line, 96)); |
| 147 | lines.push( |
| 148 | buying |
| 149 | ? 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) |
| 150 | : 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), |
| 151 | ); |
| 152 | out(`${esc}H${esc}J` + lines.join("\r\n")); |
| 153 | } |
| 154 | |
| 155 | const BUYS: Record<string, string> = { |
| 156 | "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator", |
| 157 | "8": "light", "9": "heavy", |
| 158 | }; |
| 159 | |
| 160 | process.stdin.setRawMode?.(true); |
| 161 | process.stdin.resume(); |
| 162 | process.stdin.setEncoding("utf8"); |
| 163 | process.stdin.on("data", (key: string) => { |
| 164 | const now = Date.now(); |
| 165 | if (key === "\x03" || (key === "q" && !buying)) { |
| 166 | out(`${esc}2J${esc}H`); |
| 167 | process.exit(0); |
| 168 | } |
| 169 | if (buying) { |
| 170 | if (BUYS[key]) send({ t: "buy", what: BUYS[key] }); |
| 171 | else if (key === "c" || key === "q") send({ t: "buy", what: key }); |
| 172 | else if (key === "b" || key === "\x1b") buying = false; |
| 173 | return; |
| 174 | } |
| 175 | if (key === "b") return void (buying = true); |
| 176 | if (key === "h" || key === "?") return void (helping = !helping); |
| 177 | if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 178 | if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 179 | if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; |
| 180 | if (key === "d") move = { mx: 1, my: move.my, until: now + 400 }; |
| 181 | if (key === "\x1b[D") aim -= Math.PI / 12; |
| 182 | if (key === "\x1b[C") aim += Math.PI / 12; |
| 183 | if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 184 | if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 185 | if (key === " ") firing = now + 220; |
| 186 | if (key === "f") using = now + 700; |
| 187 | if (key === "r") send({ t: "i", r: true }); |
| 188 | if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key }); |
| 189 | }); |
| 190 | |
| 191 | // Twenty times a second, the same intent the browser sends. |
| 192 | setInterval(() => { |
| 193 | const now = Date.now(); |
| 194 | if (now > move.until) move = { mx: 0, my: 0, until: 0 }; |
| 195 | send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false }); |
| 196 | }, 50); |
| 197 | setInterval(draw, 120); |
| 198 | process.stdout.on("resize", draw); |
| 199 | |
| 200 | out(`${esc}2J`); |
| 201 | draw(); |
| 202 | void connect(); |