rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 52f9b27d06119c6bfd785e71a5f60ac5478147af
| 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 | const base = |
| 11 | process.env.VAL_URL ?? |
| 12 | (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? |
| 13 | process.env.FORGE_APP_URL ?? |
| 14 | "http://127.0.0.1:8080"; |
| 15 | |
| 16 | const esc = "\x1b["; |
| 17 | const out = (s: string): void => void process.stdout.write(s); |
| 18 | |
| 19 | type Snapshot = { |
| 20 | phase: string; |
| 21 | clock: number; |
| 22 | round: number; |
| 23 | score: { red: number; blue: number }; |
| 24 | attackers: string; |
| 25 | result: string | null; |
| 26 | me: Record<string, any>; |
| 27 | players: Record<string, any>[]; |
| 28 | zones: { k: string; x: number; y: number; r: number }[]; |
| 29 | spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null }; |
| 30 | board: Record<string, any>[]; |
| 31 | events: string[]; |
| 32 | }; |
| 33 | |
| 34 | let map: { rows: string[]; tile: number } | null = null; |
| 35 | let snap: Snapshot | null = null; |
| 36 | let socket: WebSocket | null = null; |
| 37 | let status = "connecting"; |
| 38 | let aim = -Math.PI / 2; |
| 39 | let move = { mx: 0, my: 0, until: 0 }; |
| 40 | let firing = 0; |
| 41 | let using = 0; |
| 42 | let buying = false; |
| 43 | |
| 44 | const send = (message: unknown): void => { |
| 45 | if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); |
| 46 | }; |
| 47 | |
| 48 | async function connect(): Promise<void> { |
| 49 | try { |
| 50 | const page = await fetch(base + "/"); |
| 51 | const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 52 | map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number }; |
| 53 | const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", { |
| 54 | headers: { cookie, origin: new URL(base).origin }, |
| 55 | } as unknown as string[]); |
| 56 | socket = ws; |
| 57 | ws.onopen = () => (status = "live"); |
| 58 | ws.onclose = () => { |
| 59 | status = "reconnecting"; |
| 60 | socket = null; |
| 61 | setTimeout(connect, 2000); |
| 62 | }; |
| 63 | ws.onmessage = (event) => { |
| 64 | snap = JSON.parse(String(event.data)) as Snapshot; |
| 65 | }; |
| 66 | } catch (error) { |
| 67 | status = `cannot reach ${base}`; |
| 68 | setTimeout(connect, 3000); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; |
| 73 | |
| 74 | function draw(): void { |
| 75 | if (!map) { |
| 76 | out(`${esc}2J${esc}Hval — ${status}\r\n`); |
| 77 | return; |
| 78 | } |
| 79 | 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) : " "))); |
| 80 | const put = (x: number, y: number, ch: string): void => { |
| 81 | const col = Math.floor(x / map!.tile); |
| 82 | const row = Math.floor(y / map!.tile); |
| 83 | if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch; |
| 84 | }; |
| 85 | |
| 86 | const lines: string[] = []; |
| 87 | if (snap) { |
| 88 | 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)); |
| 89 | if (snap.spike.x !== null && snap.spike.y !== null) |
| 90 | put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33)); |
| 91 | for (const player of snap.players) { |
| 92 | if (!player.alive) continue; |
| 93 | const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31); |
| 94 | put(player.x, player.y, mark); |
| 95 | } |
| 96 | const me = snap.me; |
| 97 | const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase; |
| 98 | lines.push( |
| 99 | `${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}]`, |
| 100 | ); |
| 101 | lines.push( |
| 102 | `${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)}°`, |
| 103 | ); |
| 104 | } else lines.push(`val — ${status}`); |
| 105 | lines.push(""); |
| 106 | for (const row of rows) lines.push(" " + row.join("")); |
| 107 | lines.push(""); |
| 108 | if (snap) { |
| 109 | for (const side of ["atk", "def"] as const) { |
| 110 | const team = snap.board |
| 111 | .filter((p) => p.side === side) |
| 112 | .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`) |
| 113 | .join(" "); |
| 114 | lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team); |
| 115 | } |
| 116 | lines.push(""); |
| 117 | for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); |
| 118 | } |
| 119 | lines.push(""); |
| 120 | lines.push( |
| 121 | buying |
| 122 | ? 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) |
| 123 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90), |
| 124 | ); |
| 125 | out(`${esc}H${esc}J` + lines.join("\r\n")); |
| 126 | } |
| 127 | |
| 128 | const BUYS: Record<string, string> = { |
| 129 | "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator", |
| 130 | "8": "light", "9": "heavy", |
| 131 | }; |
| 132 | |
| 133 | process.stdin.setRawMode?.(true); |
| 134 | process.stdin.resume(); |
| 135 | process.stdin.setEncoding("utf8"); |
| 136 | process.stdin.on("data", (key: string) => { |
| 137 | const now = Date.now(); |
| 138 | if (key === "\x03" || (key === "q" && !buying)) { |
| 139 | out(`${esc}2J${esc}H`); |
| 140 | process.exit(0); |
| 141 | } |
| 142 | if (buying) { |
| 143 | if (BUYS[key]) send({ t: "buy", what: BUYS[key] }); |
| 144 | else if (key === "c" || key === "q") send({ t: "buy", what: key }); |
| 145 | else if (key === "b" || key === "\x1b") buying = false; |
| 146 | return; |
| 147 | } |
| 148 | if (key === "b") return void (buying = true); |
| 149 | if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 150 | if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 151 | if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; |
| 152 | if (key === "d") move = { mx: 1, my: move.my, until: now + 400 }; |
| 153 | if (key === "\x1b[D") aim -= Math.PI / 12; |
| 154 | if (key === "\x1b[C") aim += Math.PI / 12; |
| 155 | if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 156 | if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 157 | if (key === " ") firing = now + 220; |
| 158 | if (key === "f") using = now + 700; |
| 159 | if (key === "r") send({ t: "i", r: true }); |
| 160 | if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key }); |
| 161 | }); |
| 162 | |
| 163 | // Twenty times a second, the same intent the browser sends. |
| 164 | setInterval(() => { |
| 165 | const now = Date.now(); |
| 166 | if (now > move.until) move = { mx: 0, my: 0, until: 0 }; |
| 167 | send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false }); |
| 168 | }, 50); |
| 169 | setInterval(draw, 120); |
| 170 | process.stdout.on("resize", draw); |
| 171 | |
| 172 | out(`${esc}2J`); |
| 173 | draw(); |
| 174 | void connect(); |