summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/game/const.ts1
-rw-r--r--src/game/index.ts113
-rw-r--r--src/game/types.ts10
-rw-r--r--src/game/utils.ts24
-rw-r--r--src/run.ts37
-rw-r--r--src/state/const.ts11
-rw-r--r--src/state/index.ts31
-rw-r--r--src/state/types.ts25
-rw-r--r--src/ui/const.ts0
-rw-r--r--src/ui/index.ts3
-rw-r--r--src/ui/inputHandler.ts53
-rw-r--r--src/ui/render.ts113
-rw-r--r--src/ui/utils.ts20
13 files changed, 441 insertions, 0 deletions
diff --git a/src/game/const.ts b/src/game/const.ts
new file mode 100644
index 0000000..8210768
--- /dev/null
+++ b/src/game/const.ts
@@ -0,0 +1 @@
+export const VELOCITY_SCALING_FACTOR = 1 / 10;
diff --git a/src/game/index.ts b/src/game/index.ts
new file mode 100644
index 0000000..3ab20ca
--- /dev/null
+++ b/src/game/index.ts
@@ -0,0 +1,113 @@
+import { GAME_SIZE, PADDLE_HEIGHT, PADDLE_WIDTH, SessionState } from "../state";
+import { VELOCITY_SCALING_FACTOR } from "./const";
+import { Action, Collider } from "./types";
+import { applyBallBounce, applyBallVelocity } from "./utils";
+
+export const advanceState = async (
+ curState: SessionState,
+ action: Action | undefined
+): Promise<SessionState> => {
+ //simulate network
+ await new Promise((res) => setTimeout(res, 15));
+
+ let candidatePaddle = curState.localPlayerGameState.paddle;
+
+ if (action === Action.MOVE_LEFT) {
+ candidatePaddle.position[0] = Math.max(0, candidatePaddle.position[0] - 1);
+ } else if (action === Action.MOVE_RIGHT) {
+ candidatePaddle.position[0] = Math.min(
+ GAME_SIZE.cols - 1 - PADDLE_WIDTH,
+ candidatePaddle.position[0] + 1
+ );
+ }
+
+ let candidateBricks = curState.localPlayerGameState.bricks;
+
+ const colliders: Collider[] = [];
+
+ //paddle collider
+ colliders.push({
+ normal: [0, -1],
+ boundingBox: [
+ {
+ min: candidatePaddle.position[0],
+ max: candidatePaddle.position[0] + PADDLE_WIDTH,
+ },
+ {
+ min: candidatePaddle.position[1],
+ max: candidatePaddle.position[1] + PADDLE_HEIGHT,
+ },
+ ],
+ });
+
+ //brick colliders
+ candidateBricks.forEach(({ position }, i) => {
+ colliders.push({
+ boundingBox: position.map((pos) => ({
+ min: pos - 0.5,
+ max: pos + 0.5,
+ })) as Collider["boundingBox"],
+ normal: [0, 1],
+ onHit: () => candidateBricks.splice(i, 1),
+ });
+ });
+
+ //wall colliders
+ colliders.push(
+ ...([
+ //left wall
+ {
+ boundingBox: [
+ { min: -1, max: 0 },
+ { min: 0, max: GAME_SIZE.rows },
+ ],
+ normal: [1, 0],
+ },
+ //top wall
+ {
+ boundingBox: [
+ { min: -1, max: GAME_SIZE.cols + 1 },
+ { min: -1, max: 0 },
+ ],
+ normal: [0, 1],
+ },
+ //right wall
+ {
+ boundingBox: [
+ { min: GAME_SIZE.cols, max: GAME_SIZE.cols + 1 },
+ { min: 0, max: GAME_SIZE.rows },
+ ],
+ normal: [-1, 0],
+ },
+ ] satisfies Collider[])
+ );
+
+ const candidateBalls = curState.localPlayerGameState.balls
+ .map((ball) => {
+ let candidateBall = applyBallVelocity(ball);
+
+ const hitCollider = colliders.find(({ boundingBox }) =>
+ candidateBall.position.every(
+ (pos, i) => pos >= boundingBox[i].min && pos <= boundingBox[i].max
+ )
+ );
+
+ if (hitCollider) {
+ hitCollider.onHit && hitCollider.onHit();
+ candidateBall = applyBallBounce(candidateBall, hitCollider.normal);
+ }
+
+ return candidateBall;
+ })
+ .filter((ball) => !!ball);
+
+ return {
+ ...curState,
+ localPlayerGameState: {
+ ...curState.localPlayerGameState,
+ bricks: candidateBricks,
+ paddle: candidatePaddle,
+ balls: candidateBalls,
+ },
+ };
+};
diff --git a/src/game/types.ts b/src/game/types.ts
new file mode 100644
index 0000000..428bb64
--- /dev/null
+++ b/src/game/types.ts
@@ -0,0 +1,10 @@
+export enum Action {
+ MOVE_LEFT = "MOVE_LEFT",
+ MOVE_RIGHT = "MOVE_RIGHT",
+}
+
+export type Collider = {
+ boundingBox: [{ min: number; max: number }, { min: number; max: number }];
+ normal: [number, number];
+ onHit?: () => any;
+};
diff --git a/src/game/utils.ts b/src/game/utils.ts
new file mode 100644
index 0000000..ec81419
--- /dev/null
+++ b/src/game/utils.ts
@@ -0,0 +1,24 @@
+import { Ball } from "../state";
+import { VELOCITY_SCALING_FACTOR } from "./const";
+
+export const applyBallVelocity = (ball: Ball): Ball => {
+ let newPos = ball.position.map(
+ (a, i) => a + ball.velocity[i] * VELOCITY_SCALING_FACTOR
+ ) as [number, number];
+ return { ...ball, position: newPos };
+};
+
+export const applyBallBounce = (ball: Ball, normal: [number, number]): Ball => {
+ //calculate reflection
+ const newVelocity = ball.velocity.map(
+ (v, i) => v - v * Math.abs(normal[i]) * 2
+ ) as [number, number];
+
+ //move the ball out of the collider
+ const newPos = ball.position.map((p, i) => p + normal[i]) as [number, number];
+
+ //this gives a little punch
+ let newBall = { velocity: newVelocity, position: newPos };
+ newBall = applyBallVelocity(newBall);
+ return newBall;
+};
diff --git a/src/run.ts b/src/run.ts
new file mode 100644
index 0000000..c798f87
--- /dev/null
+++ b/src/run.ts
@@ -0,0 +1,37 @@
+import { advanceState } from "./game";
+import { Action } from "./game/types";
+import { createSessionState, 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) => {
+ if (actionQueue.length > 1) {
+ actionQueue = actionQueue.slice(1);
+ }
+ switch (key) {
+ case Key.LEFT_ARROW:
+ actionQueue.push(Action.MOVE_LEFT);
+ break;
+ case Key.RIGHT_ARROW:
+ actionQueue.push(Action.MOVE_RIGHT);
+ break;
+ default:
+ break;
+ }
+ };
+
+ prepareTerminal();
+
+ createAndBindHandler(updateAction, process.exit);
+
+ while (true) {
+ let nextAction = actionQueue.pop();
+ state = await advanceState(state, nextAction);
+ renderState(state);
+ }
+};
+
+run().then(console.log).catch(console.error);
diff --git a/src/state/const.ts b/src/state/const.ts
new file mode 100644
index 0000000..caac8ce
--- /dev/null
+++ b/src/state/const.ts
@@ -0,0 +1,11 @@
+export const GAME_SIZE = {
+ rows: 20,
+ cols: 20,
+};
+
+export const INITIAL_PADDLE_POSITION = 10;
+
+export const PADDLE_WIDTH = 3;
+export const PADDLE_HEIGHT = 1;
+
+export const NUM_STARTING_BALLS = 2;
diff --git a/src/state/index.ts b/src/state/index.ts
new file mode 100644
index 0000000..03d218f
--- /dev/null
+++ b/src/state/index.ts
@@ -0,0 +1,31 @@
+import { GAME_SIZE, NUM_STARTING_BALLS } from "./const";
+import { Ball, GameState, SessionState } from "./types";
+
+export * from "./const";
+export * from "./types";
+
+const createRandomBall = (): Ball => ({
+ position: [
+ GAME_SIZE.cols / 4 + (Math.random() * GAME_SIZE.cols) / 2,
+ GAME_SIZE.rows / 4 + (Math.random() * GAME_SIZE.rows) / 2,
+ ],
+ velocity: [-1 + Math.random() * 2, 0.5 + Math.random()],
+});
+
+const createGameState = () =>
+ ({
+ paddle: { position: [0, GAME_SIZE.rows - 1] },
+ 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)],
+ })),
+ } satisfies GameState);
+
+export const createSessionState = (sessionId: string) =>
+ ({
+ sessionId,
+ localPlayerGameState: createGameState(),
+ remotePlayerGameState: createGameState(),
+ } satisfies SessionState);
diff --git a/src/state/types.ts b/src/state/types.ts
new file mode 100644
index 0000000..202e65e
--- /dev/null
+++ b/src/state/types.ts
@@ -0,0 +1,25 @@
+export type Paddle = {
+ position: [number, number];
+};
+
+export type Ball = {
+ //float positions
+ position: [number, number];
+ velocity: [number, number];
+};
+
+export type Brick = {
+ position: [number, number];
+};
+
+export type GameState = {
+ paddle: Paddle;
+ balls: Ball[];
+ bricks: Brick[];
+};
+
+export type SessionState = {
+ sessionId: string;
+ localPlayerGameState: GameState;
+ remotePlayerGameState: GameState;
+};
diff --git a/src/ui/const.ts b/src/ui/const.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/ui/const.ts
diff --git a/src/ui/index.ts b/src/ui/index.ts
new file mode 100644
index 0000000..6a79a81
--- /dev/null
+++ b/src/ui/index.ts
@@ -0,0 +1,3 @@
+export * from "./render";
+export * from "./inputHandler";
+export * from "./utils";
diff --git a/src/ui/inputHandler.ts b/src/ui/inputHandler.ts
new file mode 100644
index 0000000..74bfdd8
--- /dev/null
+++ b/src/ui/inputHandler.ts
@@ -0,0 +1,53 @@
+export enum Key {
+ CTRL_C = "ctrl_c",
+ LEFT_ARROW = "left_arrow",
+ RIGHT_ARROW = "right_arrow",
+}
+
+const keyToBuffer: Record<Key, Buffer> = {
+ [Key.CTRL_C]: Buffer.from("\u0003"),
+ [Key.LEFT_ARROW]: Buffer.from("\u001b[D"),
+ [Key.RIGHT_ARROW]: Buffer.from("\u001b[C"),
+};
+
+export const createAndBindHandler = (
+ onInput: (key: Key) => any,
+ onExit: () => any
+) => {
+ process.stdin.setRawMode(true);
+
+ let seq = Buffer.alloc(0);
+
+ process.stdin.on("data", (inputBuffer) => {
+ seq = Buffer.concat([seq, inputBuffer]);
+
+ for (const [key, buffer] of Object.entries(keyToBuffer) as [
+ Key,
+ Buffer
+ ][]) {
+ if (
+ !(
+ seq.length >= buffer.length &&
+ seq.slice(-buffer.length).equals(buffer)
+ )
+ ) {
+ continue;
+ }
+ {
+ if (key === Key.CTRL_C) {
+ onExit();
+ } else {
+ onInput(key);
+ }
+ seq = Buffer.alloc(0);
+ return;
+ }
+ }
+
+ if (seq.length > 6) {
+ seq = Buffer.alloc(0);
+ }
+ });
+
+ process.stdin.resume();
+};
diff --git a/src/ui/render.ts b/src/ui/render.ts
new file mode 100644
index 0000000..95464dc
--- /dev/null
+++ b/src/ui/render.ts
@@ -0,0 +1,113 @@
+import { SessionState, GAME_SIZE, PADDLE_WIDTH, GameState } from "../state";
+import readline from "node:readline";
+import {
+ clearTerminal,
+ getCurrentTerminalSize,
+ TERM_SIZE as RENDER_SIZE,
+} from "./utils";
+
+let lastTermSize: ReturnType<typeof getCurrentTerminalSize> | undefined;
+
+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) {
+ rowOut = rowOut.concat("--".repeat(GAME_SIZE.cols + 2));
+ } else {
+ for (let col = -1; col < GAME_SIZE.cols + 1; col++) {
+ if (col === -1 || col === GAME_SIZE.cols) {
+ rowOut = rowOut.concat("||");
+ } else {
+ const [paddleX, paddleY] = gameState.paddle.position;
+ const paddleXMin = paddleX;
+ const paddleXMax = paddleX + PADDLE_WIDTH;
+
+ const ballPositions = gameState.balls.map(({ position }) => position);
+
+ const brickPositions = gameState.bricks.map(
+ ({ position }) => position
+ );
+
+ const hasPaddle =
+ col >= paddleXMin && col <= paddleXMax && row === paddleY;
+
+ const firstBall = ballPositions.find(
+ ([ballX, ballY]) =>
+ col === Math.round(ballX) && row === Math.round(ballY)
+ );
+
+ const hasBrick = brickPositions.some(
+ ([brickX, brickY]) => col === brickX && row === brickY
+ );
+
+ if (hasPaddle) {
+ rowOut = rowOut.concat("##");
+ } else if (firstBall) {
+ const fx = firstBall[0] - Math.round(firstBall[0]);
+
+ let chars;
+ if (fx < 0) {
+ chars = "O ";
+ } else {
+ chars = " O";
+ }
+
+ rowOut = rowOut.concat(chars);
+ } else if (hasBrick) {
+ rowOut = rowOut.concat("▒▒");
+ } else {
+ rowOut = rowOut.concat(" ");
+ }
+ }
+ }
+ }
+
+ rows.push(rowOut);
+ }
+ return rows;
+};
+
+export const renderState = (sessionState: SessionState) => {
+ const rl = new readline.promises.Readline(process.stdout, {
+ autoCommit: true,
+ });
+
+ rl.cursorTo(0, 0);
+
+ const termSize = getCurrentTerminalSize();
+ if (
+ lastTermSize &&
+ (lastTermSize.cols !== termSize.cols || lastTermSize.rows !== termSize.rows)
+ ) {
+ clearTerminal();
+ }
+ lastTermSize = termSize;
+
+ if (termSize.cols < RENDER_SIZE.cols || termSize.rows < RENDER_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;
+
+ 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"))
+ );
+
+ process.stdout.write(allOut);
+};
diff --git a/src/ui/utils.ts b/src/ui/utils.ts
new file mode 100644
index 0000000..32de02e
--- /dev/null
+++ b/src/ui/utils.ts
@@ -0,0 +1,20 @@
+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 getCurrentTerminalSize = (): { rows: number; cols: number } => {
+ const { rows, columns } = process.stdout;
+ return { rows, cols: columns };
+};
+
+export const clearTerminal = () => {
+ process.stdout.write("\x1Bc");
+};
+
+export const prepareTerminal = () => {
+ clearTerminal();
+};