diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/connection/index.ts | 197 | ||||
-rw-r--r-- | src/game/index.ts | 31 | ||||
-rw-r--r-- | src/run.ts | 52 | ||||
-rw-r--r-- | src/state/const.ts | 5 | ||||
-rw-r--r-- | src/state/index.ts | 39 | ||||
-rw-r--r-- | src/state/types.ts | 9 | ||||
-rw-r--r-- | src/ui/render.ts | 71 | ||||
-rw-r--r-- | src/ui/utils.ts | 11 |
8 files changed, 376 insertions, 39 deletions
diff --git a/src/connection/index.ts b/src/connection/index.ts new file mode 100644 index 0000000..828fcb5 --- /dev/null +++ b/src/connection/index.ts @@ -0,0 +1,197 @@ +import net, { Socket } from "node:net"; +import { SessionState } from "../state"; +import { TextEncoder } from "node:util"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const Headers = { + BEGIN_FRAME: Buffer.from(encoder.encode("BEGIN_FRAME")), + END_FRAME: Buffer.from(encoder.encode("END_FRAME")), + STATE_MSG: Buffer.from(encoder.encode("BEGIN_STATE_MSG")), +}; + +export class Connection { + private readBuffer: Buffer = Buffer.alloc(0); + private frameBuffer: Uint8Array[] = []; + + private handleData(data: Buffer) { + // append to buffer + this.readBuffer = Buffer.concat([this.readBuffer, data]); + + while (true) { + if (this.readBuffer.length < Headers.BEGIN_FRAME.length + 4) { + return; + } + + if ( + !this.readBuffer + .subarray(0, Headers.BEGIN_FRAME.length) + .equals(Headers.BEGIN_FRAME) + ) { + throw new Error("Missing BEGIN_FRAME!"); + } + + const lengthOffset = Headers.BEGIN_FRAME.length; + const messageLength = this.readBuffer.readUInt32LE(lengthOffset); + + const totalFrameLength = + Headers.BEGIN_FRAME.length + + 4 + + messageLength + + Headers.END_FRAME.length; + + if (this.readBuffer.length < totalFrameLength) { + return; + } + + const messageStart = lengthOffset + 4; + const messageEnd = messageStart + messageLength; + const message = this.readBuffer.subarray(messageStart, messageEnd); + + const endFrameStart = messageEnd; + if ( + !this.readBuffer + .subarray(endFrameStart, endFrameStart + Headers.END_FRAME.length) + .equals(Headers.END_FRAME) + ) { + console.log(decoder.decode(this.readBuffer)); + console.log( + this.readBuffer.subarray( + endFrameStart, + endFrameStart + Headers.END_FRAME.length + ) + ); + throw new Error("Invalid END_FRAME!"); + } + + this.frameBuffer.push(message); + + this.readBuffer = this.readBuffer.subarray(totalFrameLength); + } + } + + constructor(private socket: Socket) { + socket.on("data", this.handleData.bind(this)); + } + + private serializeState(state: SessionState): Uint8Array { + const json = JSON.stringify(state); + return encoder.encode(json); + } + + private deserializeState(state: Uint8Array): SessionState { + const json = decoder.decode(state); + return JSON.parse(json); + } + + public sendState(state: SessionState) { + const serializedState = this.serializeState(state); + const message = new Uint8Array([...Headers.STATE_MSG, ...serializedState]); + + const len = Buffer.alloc(4); + len.writeUInt32LE(message.length); + + const msg = new Uint8Array([ + ...Headers.BEGIN_FRAME, + ...len, + ...message, + ...Headers.END_FRAME, + ]); + + this.socket.write(msg); + } + + public tryReadState(): SessionState | undefined { + const frame = this.frameBuffer.find((frame) => { + return Headers.STATE_MSG.every((v, i) => frame[i] === v); + }); + + if (!frame) { + return; + } + + this.frameBuffer.splice(this.frameBuffer.indexOf(frame), 1); + + const msg = frame.slice(Headers.STATE_MSG.length); + const state = this.deserializeState(msg); + + return state; + } +} + +export const advanceStateRemote = async ( + connection: Connection, + sessionState: SessionState +): Promise<SessionState> => { + const remoteState = await new Promise<SessionState>((res, rej) => { + const timeout = setTimeout(rej, 2500); + const interval = setInterval(async () => { + const state = connection.tryReadState(); + if (state) { + clearInterval(timeout); + clearInterval(interval); + res(state); + } + }); + }); + + if (remoteState.seqno !== sessionState.seqno) { + throw new Error(`Misaligned seqno!`); + } + return { + ...sessionState, + inboundEventQueue: remoteState.outboundEventQueue, + remotePlayerGameState: remoteState.localPlayerGameState, + }; +}; + +export const getConnection = async ({ + localPort, + remotePort, + hostname, +}: { + localPort?: number; + remotePort?: number; + hostname?: string; +}) => { + return new Promise<Connection>(async (res, rej) => { + let serverSocket: Socket | undefined; + let clientSocket: Socket | undefined; + + const handleConnection = () => { + if (serverSocket) { + res(new Connection(serverSocket)); + return; + } else if (clientSocket) { + res(new Connection(clientSocket)); + return; + } + + throw new Error("No valid connection!"); + }; + + if (remotePort) { + console.log(`Trying to connect on ${remotePort}`); + if (!hostname) { + throw new Error( + `A hostname is required when connecting to a remote machine` + ); + } + clientSocket = net.createConnection( + remotePort, + hostname, + handleConnection + ); + } else if (localPort) { + console.log(`Listening on ${localPort}`); + const server = net.createServer((socket) => { + serverSocket = socket; + handleConnection(); + }); + server.listen(localPort); + } else { + throw new Error(`Must provide localPort OR remotePort`); + } + }); +}; diff --git a/src/game/index.ts b/src/game/index.ts index 3ab20ca..62debf0 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -1,15 +1,22 @@ -import { GAME_SIZE, PADDLE_HEIGHT, PADDLE_WIDTH, SessionState } from "../state"; -import { VELOCITY_SCALING_FACTOR } from "./const"; +import { + createRandomBall, + Event, + GAME_SIZE, + PADDLE_HEIGHT, + PADDLE_WIDTH, + SessionState, +} from "../state"; import { Action, Collider } from "./types"; import { applyBallBounce, applyBallVelocity } from "./utils"; -export const advanceState = async ( +export const advanceStateLocal = async ( curState: SessionState, action: Action | undefined ): Promise<SessionState> => { - //simulate network + //minimum delay await new Promise((res) => setTimeout(res, 15)); + let candidateOutboundEventQueue: Event[] = []; let candidatePaddle = curState.localPlayerGameState.paddle; if (action === Action.MOVE_LEFT) { @@ -48,7 +55,10 @@ export const advanceState = async ( max: pos + 0.5, })) as Collider["boundingBox"], normal: [0, 1], - onHit: () => candidateBricks.splice(i, 1), + onHit: () => { + candidateOutboundEventQueue.push({ name: "NEW_BALL" }); + candidateBricks.splice(i, 1); + }, }); }); @@ -101,13 +111,24 @@ export const advanceState = async ( }) .filter((ball) => !!ball); + //apply incoming events + for (const event of curState.inboundEventQueue) { + switch (event.name) { + case "NEW_BALL": + candidateBalls.push(createRandomBall()); + } + } + return { ...curState, + inboundEventQueue: [], + outboundEventQueue: candidateOutboundEventQueue, localPlayerGameState: { ...curState.localPlayerGameState, bricks: candidateBricks, paddle: candidatePaddle, balls: candidateBalls, }, + seqno: curState.seqno + 1, }; }; @@ -1,10 +1,15 @@ -import { advanceState } from "./game"; +import readline from "node:readline/promises"; +import { advanceStateRemote, Connection, getConnection } from "./connection"; +import { advanceStateLocal } from "./game"; import { Action } from "./game/types"; -import { createSessionState, SessionState } from "./state"; +import { + createLocalSessionState, + createNetworkedSessionState, + SessionState, +} from "./state"; import { renderState, createAndBindHandler, prepareTerminal, Key } from "./ui"; export const run = async () => { - let state: SessionState = createSessionState("xyz"); let actionQueue: Action[] = []; const updateAction = (key: Key) => { @@ -23,13 +28,48 @@ export const run = async () => { } }; - prepareTerminal(); - createAndBindHandler(updateAction, process.exit); + let connection: Connection; + const rl = readline.createInterface(process.stdin, process.stdout); + const solo = ["y", ""].includes( + (await rl.question("Play solo? (Y/n) > ")).trim().toLowerCase() + ); + + let state: SessionState; + + if (!solo) { + state = createNetworkedSessionState("NETWORKED-SESSION"); + const host = ["y", ""].includes( + (await rl.question("Host? (Y/n) > ")).trim().toLowerCase() + ); + if (host) { + console.log(`Starting the server...`); + connection = await getConnection({ localPort: 9932 }); + } else { + const hostname = + (await rl.question("Enter remote hostname [localhost] > ")).trim() || + "localhost"; + connection = await getConnection({ remotePort: 9932, hostname }); + } + + try { + } catch {} + } else { + state = createLocalSessionState("LOCAL-GAME"); + } + + prepareTerminal(); + while (true) { let nextAction = actionQueue.pop(); - state = await advanceState(state, nextAction); + + state = await advanceStateLocal(state, nextAction); + if (state.remotePlayerGameState) { + connection!.sendState(state); + state = await advanceStateRemote(connection!, state); + } + renderState(state); } }; diff --git a/src/state/const.ts b/src/state/const.ts index caac8ce..380eb99 100644 --- a/src/state/const.ts +++ b/src/state/const.ts @@ -8,4 +8,7 @@ export const INITIAL_PADDLE_POSITION = 10; export const PADDLE_WIDTH = 3; export const PADDLE_HEIGHT = 1; -export const NUM_STARTING_BALLS = 2; +export const NUM_STARTING_BALLS = 1; + +export const NUM_BRICKS = GAME_SIZE.cols * 5; +export const BRICK_DISTRIBUTION = 0.9; diff --git a/src/state/index.ts b/src/state/index.ts index 03d218f..6999188 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,10 +1,15 @@ -import { GAME_SIZE, NUM_STARTING_BALLS } from "./const"; -import { Ball, GameState, SessionState } from "./types"; +import { + BRICK_DISTRIBUTION, + GAME_SIZE, + NUM_BRICKS, + NUM_STARTING_BALLS, +} from "./const"; +import { Ball, Brick, GameState, SessionState } from "./types"; export * from "./const"; export * from "./types"; -const createRandomBall = (): Ball => ({ +export const createRandomBall = (): Ball => ({ position: [ GAME_SIZE.cols / 4 + (Math.random() * GAME_SIZE.cols) / 2, GAME_SIZE.rows / 4 + (Math.random() * GAME_SIZE.rows) / 2, @@ -18,14 +23,34 @@ const createGameState = () => balls: new Array(NUM_STARTING_BALLS) .fill(undefined) .map(() => createRandomBall()), - bricks: new Array(GAME_SIZE.cols * 5).fill(undefined).map((_, i) => ({ - position: [i % GAME_SIZE.cols, Math.floor(i / GAME_SIZE.cols)], - })), + bricks: new Array(NUM_BRICKS) + .fill(undefined) + .map((_, i) => + Math.random() < BRICK_DISTRIBUTION + ? ({ + position: [i % GAME_SIZE.cols, Math.floor(i / GAME_SIZE.cols)], + } as Brick) + : undefined + ) + .filter((b) => !!b), } satisfies GameState); -export const createSessionState = (sessionId: string) => +export const createLocalSessionState = (sessionId: string) => + ({ + sessionId, + seqno: 0, + localPlayerGameState: createGameState(), + remotePlayerGameState: undefined, + inboundEventQueue: [], + outboundEventQueue: [], + } satisfies SessionState); + +export const createNetworkedSessionState = (sessionId: string) => ({ sessionId, + seqno: 0, localPlayerGameState: createGameState(), remotePlayerGameState: createGameState(), + inboundEventQueue: [], + outboundEventQueue: [], } satisfies SessionState); diff --git a/src/state/types.ts b/src/state/types.ts index 202e65e..517be17 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -18,8 +18,15 @@ export type GameState = { bricks: Brick[]; }; +export type NewBallEvent = { name: "NEW_BALL" }; + +export type Event = NewBallEvent; + export type SessionState = { sessionId: string; + seqno: number; localPlayerGameState: GameState; - remotePlayerGameState: GameState; + remotePlayerGameState: GameState | undefined; + inboundEventQueue: Event[]; + outboundEventQueue: Event[]; }; diff --git a/src/ui/render.ts b/src/ui/render.ts index 95464dc..79451ae 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -3,15 +3,19 @@ import readline from "node:readline"; import { clearTerminal, getCurrentTerminalSize, - TERM_SIZE as RENDER_SIZE, + RENDER_GAME_SIZE, + RENDER_STATE_SIZE, } from "./utils"; let lastTermSize: ReturnType<typeof getCurrentTerminalSize> | undefined; +let lastRender: { seqno: number; time: number } = { + seqno: 0, + time: Date.now(), +}; export const renderGameState = (gameState: GameState): string[] => { let rows: string[] = []; for (let row = -1; row < GAME_SIZE.rows + 1; row++) { - // let rowOut: string = " ".repeat(marginCols); let rowOut: string = " "; if (row === -1) { @@ -86,28 +90,63 @@ export const renderState = (sessionState: SessionState) => { } lastTermSize = termSize; - if (termSize.cols < RENDER_SIZE.cols || termSize.rows < RENDER_SIZE.rows) { + if ( + termSize.cols < RENDER_STATE_SIZE.cols || + termSize.rows < RENDER_STATE_SIZE.rows + ) { process.stdout.write("Please increase the screen size"); return; } - const marginCols = (termSize.cols - RENDER_SIZE.cols) / 2; - const marginRows = (termSize.rows - RENDER_SIZE.rows) / 2; + const marginCols = termSize.cols - RENDER_STATE_SIZE.cols; + const marginRows = (termSize.rows - RENDER_STATE_SIZE.rows) / 2; let allOut: string = "\n".repeat(marginRows); const localDisplay = renderGameState(sessionState.localPlayerGameState); - const remoteDisplay = renderGameState(sessionState.remotePlayerGameState); - - localDisplay.forEach( - (row, i) => - (allOut = allOut - .concat(" ".repeat(marginCols / 2)) - .concat(row) - .concat(" ".repeat(marginCols / 2)) - .concat(remoteDisplay[i]) - .concat("\n")) - ); + const remoteDisplay = + sessionState.remotePlayerGameState && + renderGameState(sessionState.remotePlayerGameState); + + const timeNow = Date.now(); + const infoHeader = `Frame: ${sessionState.seqno} +Session: ${sessionState.sessionId} +Fps: ${( + ((sessionState.seqno - lastRender.seqno) / (timeNow - lastRender.time)) * + 1000 + ).toFixed(0)}\n`; + + if (remoteDisplay) { + allOut = allOut + .concat(infoHeader) + .concat(" ".repeat(marginCols / 2)) + .concat(`LOCAL:`) + .concat(" ".repeat(RENDER_GAME_SIZE.cols)) + .concat(" ".repeat(4)) + .concat(`REMOTE:`) + .concat("\n"); + localDisplay.forEach( + (row, i) => + (allOut = allOut + .concat(" ".repeat(marginCols / 2)) + .concat(row) + .concat(" ".repeat(8)) + .concat(remoteDisplay[i]) + .concat(" ".repeat(marginCols / 2)) + .concat("\n")) + ); + } else { + allOut = allOut.concat(infoHeader).concat("\n"); + localDisplay.forEach( + (row, i) => + (allOut = allOut + .concat(" ".repeat(marginCols / 2 + 4)) + .concat(row) + .concat(" ".repeat(marginCols / 2 + 4)) + .concat("\n")) + ); + } process.stdout.write(allOut); + lastRender = { seqno: sessionState.seqno, time: timeNow }; }; diff --git a/src/ui/utils.ts b/src/ui/utils.ts index 32de02e..9f5e8bc 100644 --- a/src/ui/utils.ts +++ b/src/ui/utils.ts @@ -1,9 +1,14 @@ import process from "node:process"; import { GAME_SIZE } from "../state"; -export const TERM_SIZE = { - rows: GAME_SIZE.rows + 2, - cols: (GAME_SIZE.cols * 2 + 2) * 2, +export const RENDER_GAME_SIZE = { + rows: GAME_SIZE.rows, + cols: GAME_SIZE.cols * 2 + 4, +}; + +export const RENDER_STATE_SIZE = { + rows: RENDER_GAME_SIZE.rows + 7, + cols: RENDER_GAME_SIZE.cols * 2 + 10, }; export const getCurrentTerminalSize = (): { rows: number; cols: number } => { |