benjivers/val Public Public: anyone can read this; the crew can change it.

Files f06c94705a19e63a135dcb573ff98fcaae80d78f
174 lines · 6.8 KB · ~2.0k tok Raw History
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();
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts