diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/game/const.ts | 1 | ||||
-rw-r--r-- | src/game/index.ts | 113 | ||||
-rw-r--r-- | src/game/types.ts | 10 | ||||
-rw-r--r-- | src/game/utils.ts | 24 | ||||
-rw-r--r-- | src/run.ts | 37 | ||||
-rw-r--r-- | src/state/const.ts | 11 | ||||
-rw-r--r-- | src/state/index.ts | 31 | ||||
-rw-r--r-- | src/state/types.ts | 25 | ||||
-rw-r--r-- | src/ui/const.ts | 0 | ||||
-rw-r--r-- | src/ui/index.ts | 3 | ||||
-rw-r--r-- | src/ui/inputHandler.ts | 53 | ||||
-rw-r--r-- | src/ui/render.ts | 113 | ||||
-rw-r--r-- | src/ui/utils.ts | 20 |
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(); +}; |