UNPKG

@haelp/teto

Version:

A typescript-based controllable TETR.IO client.

451 lines (450 loc) 18.1 kB
import { Engine } from "../../engine/index.mjs"; import { getFullFrame } from "./utils.mjs"; import chalk from "chalk"; const moveElementToFirst = (arr, n)=>[ arr[n], ...arr.slice(0, n), ...arr.slice(n + 1) ]; export class Game { client; listeners = []; frameQueue = []; incomingGarbage = []; timeout = null; messageQueue = []; startTime = null; #target = { strategy: "even" }; tick; // @ts-expect-error isPractice = false; #pauseIGEs = false; #forcePauseIGEs = false; #igeQueue = []; #slowTickWarning = false; /** The client's engine */ engine; /** Data on all players in game, including the client */ players = []; get opponents() { return this.players.filter((p)=>p.gameid !== this.gameid); } /** The client's `gameid` set by the server */ gameid; /** The raw game config sent by TETR.IO */ options; /** The raw game config for all players, including the client's own game config */ readyData; /** The targets set by the server */ serverTargets = []; /** The gameids of the users targeting the client */ enemies = []; /** The keys the client has queued to press (allows for pressing keys in the future) */ keyQueue = []; /** Whether or not targeting is allowed (changed by server). Setting target while this is `false` will throw an error. */ canTarget = true; /** Whether or not the client's game is over (topped out), and no longer ticking. */ over = false; /** The BotWrapper in use. When a BotWrapper is set, `tick` will not be called. */ botWrapper; /** The last time IGEs were flushed */ lastIGEFlush = performance.now(); /** The Frames Per Second of the TETR.IO engine */ static fps = 60; /** The maximum amount of time before all IGEs are force-flushed */ static maxIGETimeout = 30000; /** Frames per message */ static fpm = 12; /** @hideconstructor */ constructor(client, ready){ this.client = client; const self = ready.players.find((p)=>p.userid === client.user.id); if (!self) throw new Error("User not in game"); this.gameid = self.gameid; this.options = self.options; this.readyData = ready; try { this.engine = this.createEngine(this.options, this.gameid); } catch (e) { console.error(e); throw e; } // including ourself means we get our own replay data ready.players.forEach((player)=>this.client.emit("game.scope.start", player.gameid)); this.init(); } #log(msg, { level = "info" } = { level: "info" }) { const func = level === "info" ? chalk.blue : level === "warning" ? chalk.yellow : chalk.red; console.log(`${func("[🎀\u2009Triangle.JS]")}: ${msg}`); } listen(event, cb, once = false) { this.listeners.push([ event, cb ]); if (once) { this.client.once(event, cb); } else { this.client.on(event, cb); } } /** Kill the game. This is called automatically by the Room class when a game ends/is aborted, you don't need to use this. */ destroy() { if (!this.over) this.stop(); this.listeners.forEach((l)=>this.client.off(l[0], l[1])); this.players.forEach((engine)=>engine.engine.events.removeAllListeners()); delete this.client.game; } /** * Stops the client's gameplay, when it dies. Does not destroy the game. */ stop() { this.listeners.forEach((l)=>l[0] !== "game.replay" && this.client.off(l[0], l[1])); this.listeners = this.listeners.filter((l)=>l[0] === "game.replay"); if (this.timeout) this.timeout = clearTimeout(this.timeout) || null; this.engine.events.removeAllListeners(); this.over = true; this.client.ribbon.fasterPing = false; } init() { this.client.ribbon.fasterPing = true; this.listen("game.match", (_data)=>{}); this.listen("game.start", ()=>{ this.timeout = setTimeout(()=>{ this.start(); }, this.options.countdown_count * this.options.countdown_interval + this.options.precountdown + this.options.prestart); this.listen("game.abort", ()=>clearTimeout(this.timeout), true); }, true); const p = this.readyData.players; // TODO: Add support for choosing who to spectate // const players = p.filter((p) => p.gameid !== this.gameid); const players = p; this.players = players.map((o)=>({ name: o.options.username, userid: o.userid, gameid: o.gameid, engine: this.createEngine(o.options, o.gameid), queue: [] })); // this.listen("game.replay.ige", (data) => this.addIGE(data)); this.listen("game.replay.ige", (data)=>this.#handleIGE(data)); this.listen("game.replay", ({ gameid, frames })=>{ const game = this.players.find((player)=>player.gameid === gameid); if (!game || game.engine.toppedOut) return false; game.queue.push(...frames); while(game.queue.some((f)=>f.frame > game.engine.frame)){ const frames = []; while(game.queue.length > 0 && game.queue[0].frame <= game.engine.frame){ frames.push(game.queue.shift()); } game.engine.tick(frames); } }); } start() { this.pipe({ type: "start", frame: 0, data: {} }, getFullFrame(this.options)); this.startTime = performance.now(); try { this.target = this.target; } catch {} this.client.emit("client.game.round.start", [ (f)=>{ this.tick = f; }, this.engine, [ ...this.players.map((player)=>({ name: player.name, gameid: player.gameid, engine: player.engine })) ] ]); this.timeout = setTimeout(this.tickGame.bind(this), 0); } createEngine(options, gameid) { return new Engine({ multiplayer: { opponents: this.readyData.players.map((o)=>o.gameid).filter((id)=>id !== gameid), passthrough: options.passthrough }, board: { width: options.boardwidth, height: options.boardheight, buffer: 20 // there is always a buffer of 20 over the visible board }, kickTable: options.kickset, options: { comboTable: options.combotable, garbageBlocking: options.garbageblocking, clutch: options.clutch, garbageTargetBonus: options.garbagetargetbonus, spinBonuses: options.spinbonuses }, queue: { minLength: 31, seed: options.seed, type: options.bagtype }, garbage: { cap: { absolute: options.garbageabsolutecap, increase: options.garbagecapincrease, max: options.garbagecapmax, value: options.garbagecap, marginTime: options.garbagecapmargin }, multiplier: { value: options.garbagemultiplier, increase: options.garbageincrease, marginTime: options.garbagemargin }, boardWidth: options.boardwidth, garbage: { speed: options.garbagespeed, holeSize: options.garbageholesize }, messiness: { change: options.messiness_change, nosame: options.messiness_nosame, timeout: options.messiness_timeout, within: options.messiness_inner, center: options.messiness_center ?? false }, bombs: options.usebombs, seed: options.seed, rounding: options.roundmode, openerPhase: options.openerphase, specialBonus: options.garbagespecialbonus }, pc: options.allclears ? { garbage: options.allclear_garbage, b2b: options.allclear_b2b } : false, b2b: { chaining: options.b2bchaining, charging: options.b2bcharging ? { at: options.b2bcharge_at, base: options.b2bcharge_base } : false }, gravity: { value: options.g, increase: options.gincrease, marginTime: options.gmargin }, misc: { movement: { infinite: options.infinite_movement, lockResets: options.lockresets, lockTime: options.locktime, may20G: options.gravitymay20g }, allowed: { spin180: options.allow180, hardDrop: options.allow_harddrop, hold: options.display_hold }, infiniteHold: options.infinite_hold, username: options.username, date: new Date() }, handling: options.handling }); } flushFrames() { let returnFrames = this.frameQueue.filter((f)=>f.frame <= this.frame); this.frameQueue = this.frameQueue.filter((f)=>f.frame > this.frame); if (!this.canTarget) returnFrames = returnFrames.filter((f)=>f.type !== "strategy" && f.type !== "manual_target"); if (!this.options.manual_allowed) returnFrames = returnFrames.filter((f)=>f.type !== "manual_target"); // move the full frame to the front as a precaution const fullFrameIndex = returnFrames.findIndex((frame)=>frame.type === "full"); if (fullFrameIndex >= 0) { returnFrames = moveElementToFirst(returnFrames, fullFrameIndex); } // move start frame to front (start -> full at the end) const startFrameIndex = returnFrames.findIndex((frame)=>frame.type === "start"); if (startFrameIndex >= 0) { returnFrames = moveElementToFirst(returnFrames, startFrameIndex); } return returnFrames; } async tickGame() { if (this.over) return; let runAfter = []; if (this.tick) { try { const res = await this.tick({ gameid: this.gameid, frame: this.frame, events: this.messageQueue.splice(0, this.messageQueue.length), engine: this.engine }); const isValidObject = (obj)=>typeof obj === "object" && obj !== null; if (res.keys) this.keyQueue.push(...res.keys.filter((k, idx)=>{ if (!isValidObject(k) || !(k.type === "keydown" || k.type === "keyup") || typeof k.frame !== "number" || isNaN(k.frame) || k.frame < this.frame || !isValidObject(k.data) || ![ "moveLeft", "moveRight", "hardDrop", "hold", "softDrop", "rotateCW", "rotate180", "rotateCCW" ].includes(k.data.key) || typeof k.data.subframe !== "number" || isNaN(k.data.subframe) || k.data.subframe < 0) { this.#log(`Invalid key event at index ${idx} passed on frame ${this.frame}:\n${JSON.stringify(k, null, 2)}`, { level: "error" }); return false; } return true; })); if (res.runAfter) runAfter.push(...res.runAfter.filter((ra, idx)=>{ if (typeof ra !== "function") { this.#log(`Invalid runAfter callback at index ${idx} passed on frame ${this.frame}.`, { level: "warning" }); return false; } return true; })); } catch (e) { if (this.over) return; throw e; } } if (this.over) return; // ideally, iges get flushed here this.#flushIGEs(); const keys = []; for(let i = this.keyQueue.length - 1; i >= 0; i--){ const key = this.keyQueue[i]; if (Math.floor(key.frame) === this.frame) { keys.push(key); this.keyQueue.splice(i, 1); } } keys.splice(0, keys.length, ...keys.reverse()); try { const { garbage } = this.engine.tick([ ...this.incomingGarbage.splice(0, this.incomingGarbage.length), ...keys ]); this.messageQueue.push(...garbage.received.map((g)=>({ type: "garbage", ...g }))); this.pipe(...keys); if (this.frame !== 0 && this.frame % Game.fpm === 0) { const frames = this.flushFrames(); this.client.emit("game.replay", { gameid: this.gameid, provisioned: this.frame, frames }); this.messageQueue.push({ type: "frameset", provisioned: this.frame, frames }); } runAfter.forEach((f)=>f()); // flush at the time `keyQueue` is most likely to be empty (so that continuous playing doesn't cause an ige backup). Garbage might be a frame late. this.#flushIGEs(); const target = (this.frame + 1) / Game.fps * 1000 - (performance.now() - this.startTime); if (target <= -2000 && !this.#slowTickWarning) { console.log(`${chalk.red("[Triangle.js]")} Triangle.js is lagging behind by more than 2 seconds! Your \`tick\` function is likely taking too long to execute.`); this.#slowTickWarning = true; } // Every half second, use timeout even if we lag to ensure the websocket can send frames if (target <= 0 && this.engine.frame % Game.fps / 2 !== 0) { return this.tickGame(); } this.timeout = setTimeout(this.tickGame.bind(this), Math.max(0, target)); } catch (e) { console.error(e); throw e; } } /** * Send raw frames to TETR.IO - * Not recommended for normal use. */ pipe(...frames) { this.frameQueue.push(...frames); } #handleIGE(data) { this.#igeQueue.push(...data.iges); this.#flushIGEs(); } #flushIGEs() { if (this.igesPaused && this.lastIGEFlush + Game.maxIGETimeout > performance.now()) return; this.#igeQueue.splice(0, this.#igeQueue.length).forEach((ige)=>this.#__internal_handleIGE(ige)); this.lastIGEFlush = performance.now(); } #__internal_handleIGE(ige) { const frame = { frame: this.frame, type: "ige", data: ige }; this.pipe(frame); this.incomingGarbage.push({ ...frame }); if (ige.type === "interaction_confirm") { if (ige.data.type === "targeted") { // TODO: implement } } else if (ige.type === "target") { this.serverTargets = ige.data.targets; } else if (ige.type === "allow_targeting") { this.canTarget = ige.data.value; } } /** * The current targeting strategy. Setting a targeting strategy throws error if targeting is not allowed. */ get target() { return this.#target; } set target(t) { if (!this.canTarget) throw new Error("Targeting not allowed."); const strategyMap = { even: 0, elims: 1, random: 2, payback: 3, manual: 4 }; const frame = this.frame; if (t.strategy === "manual") { this.pipe({ frame, type: "manual_target", data: t.target }); } else { this.pipe({ frame, type: "strategy", data: strategyMap[t.strategy] }); } this.#target = t; } /** * The game's current frame */ get frame() { return this.engine ? this.engine.frame : 0; } set frame(newFrame) { this.engine.frame = newFrame; } /** Pauses accepting IGEs (garbage events) when the `keyQueue` has items, when `pauseIGEs` is set to true. */ get pauseIGEs() { return this.#pauseIGEs; } set pauseIGEs(value) { this.#pauseIGEs = value; this.#flushIGEs(); } /** Force stops the processing of all IGEs (garbage, etc). If left `true` for too long (~30s), IGEs will be automatically flushed to avoid a forced disconnect. Use sparingly. */ get forcePauseIGEs() { return this.#forcePauseIGEs; } set forcePauseIGEs(value) { this.#forcePauseIGEs = value; this.#flushIGEs(); } /** Whether or not the `Game` is currently allowed to process IGEs. */ get igesPaused() { return this.#forcePauseIGEs || this.#pauseIGEs && this.keyQueue.length > 0; } } //# sourceMappingURL=index.js.map