diff --git a/README.md b/README.md
index 75036aa..44ba260 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ Client-side and server-side deployment is set-up automatically with `production`
Aesthetic radar charts created for skills using [recharts](https://recharts.org/en-US)
-## 🚧 Roadmap
+
## 🎨 Design Reference
-
+| Blue | ![#667EEA](https://placehold.co/15x15/667EEA/667EEA.png) #667EEA |
+| Purple | ![#764BA2](https://placehold.co/15x15/764BA2/764BA2.png) #764BA2 |
+| Dark Color | ![#1A202C](https://placehold.co/15x15/1a202c/1a202c.png) #1A202C |
+| Light Color| ![#FFFFFF](https://placehold.co/15x15/ffffff/ffffff.png) #FFFFFF |
#### Fonts
@@ -106,11 +106,11 @@ If you have any feedback, please reach out to me at ammar.ahmed1@uwaterloo.ca. I
- [TypeGraphQL](https://typegraphql.com/docs/getting-started.html)
- [Typegoose](https://typegoose.github.io/typegoose/)
-#### Chess (Coming soon...)
+
diff --git a/src/Router.tsx b/src/Router.tsx
index 5765376..99b4cca 100644
--- a/src/Router.tsx
+++ b/src/Router.tsx
@@ -1,11 +1,14 @@
import React from "react";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
+import { Box } from "@chakra-ui/react";
// Pages
import Home from "./pages/Home";
import About from "./pages/About";
import Blog from "./pages/Blog";
import Post from "./pages/Post";
+import Arcade from "./pages/Arcade";
+import Game from "./pages/Game";
// import ChessRouter from "./components/Chess/Routers/ChessRouter";
import ChessRouter from "./chess/ChessRouter";
@@ -60,6 +63,22 @@ const Router: React.FC = () => {
element: ,
children: chessRoutes,
},
+ {
+ path: '/arcade',
+ element: (
+
+
+
+ )
+ },
+ {
+ path: "/arcade/:game",
+ element: (
+
+
+
+ )
+ }
]);
return ;
diff --git a/src/chess/components/Square.tsx b/src/chess/components/Square.tsx
index 307afcf..6849114 100644
--- a/src/chess/components/Square.tsx
+++ b/src/chess/components/Square.tsx
@@ -51,7 +51,7 @@ const Square: React.FC = ({
if (board.isInCheck(piece.color)) {
// if check removers returns empty array (CHECKMATE!)
updateValidMoves(
- board.onlyCheckRemovers(rank, file, piece.color, moves) // filter to only moves that remove check
+ board.onlyCheckRemovers(rank, file, piece.color, moves, board) // filter to only moves that remove check
);
} else {
updateValidMoves(moves);
diff --git a/src/chess/contexts/GameContext.tsx b/src/chess/contexts/GameContext.tsx
index c8df5b9..b367ac6 100644
--- a/src/chess/contexts/GameContext.tsx
+++ b/src/chess/contexts/GameContext.tsx
@@ -143,7 +143,7 @@ export const GameProvider: React.FC = ({
useEffect(() => {
if (move.moveTo && move.toMove) {
// swap and update fen
- const response = FENHelper.executeMove(fen, move.toMove, move.moveTo);
+ const response = FENHelper.executeMove(fen, move.toMove, move.moveTo, boardOpts);
// console.log(response.fen);
setFEN(response.fen);
if (response.take) {
diff --git a/src/chess/game/Board.tsx b/src/chess/game/Board.tsx
index e33241d..c24c369 100644
--- a/src/chess/game/Board.tsx
+++ b/src/chess/game/Board.tsx
@@ -31,7 +31,50 @@ export class Board {
this.userColor = opts?.userColor ?? "w";
}
- public getPiece = (rank: number, numberFile: number): BoardMatrixType => {
+ get boardOpts(): BoardOpts {
+ return {
+ colorToMove: this.colorToMove as "w" | "b",
+ castling: this.castling,
+ enPassant: this.enPassant,
+ userColor: this.userColor as "w" | "b",
+ halfMove: this.halfMove,
+ fullMove: this.fullMove,
+ squareSize: this.squareSize
+ }
+ }
+
+ public canCastle = (color: "w" | "b", queenSide: boolean) => {
+ const whiteKing = this.castling[0];
+ const whiteQueen = this.castling[1];
+ const blackKing = this.castling[2];
+ const blackQueen = this.castling[3];
+
+ if (color === "w" && queenSide && whiteQueen === "Q") {
+ return true;
+ }
+
+ if (color === "w" && !queenSide && whiteKing === "K") {
+ return true;
+ }
+
+ if (color === "b" && queenSide && blackQueen === "q") {
+ return true;
+ }
+
+ if (color === "b" && !queenSide && blackKing === "k") {
+ return true;
+ }
+
+ return false;
+ }
+
+ public getPiece = (rank: number, file: number | string): BoardMatrixType => {
+ let numberFile;
+ if (typeof file === "string" ) {
+ numberFile = fileToNumber(file);
+ } else {
+ numberFile = file;
+ }
const row = 8 - rank;
const col = numberFile - 1;
@@ -118,14 +161,16 @@ export class Board {
rank: number,
file: string,
color: "w" | "b",
- moves: string[]
+ moves: string[],
+ board: Board
): string[] => {
return moves.filter((move) => {
const [moveFile, moveRank] = move.split("");
const simulated = FENHelper.executeMove(
this.fen,
{ rank, file },
- { rank: parseInt(moveRank), file: moveFile }
+ { rank: parseInt(moveRank), file: moveFile },
+ board.boardOpts
);
const simulatedBoard = new Board(simulated.fen);
return !simulatedBoard.isInCheck(color);
diff --git a/src/chess/game/FENHelper.ts b/src/chess/game/FENHelper.ts
index fa0321c..b9d6db4 100644
--- a/src/chess/game/FENHelper.ts
+++ b/src/chess/game/FENHelper.ts
@@ -5,6 +5,7 @@ interface MoveExecutionResponse {
fen: string;
take: BoardMatrixType;
matrix: BoardMatrixType[][];
+ boardOpts: BoardOpts;
}
export class FENHelper {
@@ -99,13 +100,28 @@ export class FENHelper {
static executeMove = (
fen: string,
toMove: IAlgebraic,
- moveTo: IAlgebraic
+ moveTo: IAlgebraic,
+ boardOpts: BoardOpts
): MoveExecutionResponse => {
const matrix = FENHelper.parseFEN(fen);
const toMoveIndex = FENHelper.algebraicToIndex(toMove);
const moveToIndex = FENHelper.algebraicToIndex(moveTo);
const takenPiece = matrix[moveToIndex.row][moveToIndex.col];
+ const movedPiece = matrix[toMoveIndex.row][toMoveIndex.col];
+ const newBoardOpts = { ...boardOpts };
+
+ if (movedPiece && movedPiece.type === "king") {
+ if (movedPiece.color === "w" && newBoardOpts.castling) {
+ newBoardOpts.castling = "--" + newBoardOpts.castling[2] + newBoardOpts.castling[3];
+ } else if (movedPiece.color === "b" && newBoardOpts.castling) {
+ newBoardOpts.castling = newBoardOpts.castling[0] + newBoardOpts.castling[1] + "--";
+ }
+ }
+
+ if (movedPiece && movedPiece.type === "rook") {
+
+ }
matrix[moveToIndex.row][moveToIndex.col] =
matrix[toMoveIndex.row][toMoveIndex.col];
@@ -115,6 +131,7 @@ export class FENHelper {
fen: FENHelper.parseMatrix(matrix),
take: takenPiece,
matrix,
+ boardOpts
};
};
}
diff --git a/src/chess/game/Pieces/King.ts b/src/chess/game/Pieces/King.ts
index 1b1d69c..6fc4145 100644
--- a/src/chess/game/Pieces/King.ts
+++ b/src/chess/game/Pieces/King.ts
@@ -85,7 +85,8 @@ export class King extends Piece {
const simulated = FENHelper.executeMove(
currFen,
{ rank, file },
- { rank: move.rank, file: numberToFile(move.file) }
+ { rank: move.rank, file: numberToFile(move.file) },
+ board.boardOpts
);
const simulatedBoard = new Board(simulated.fen);
@@ -137,6 +138,60 @@ export class King extends Piece {
const moves = valid.map((move) => createAlgebraic(move.rank, move.file));
+ const firstRank = this.color === "w" ? 1 : 8;
+ if (rank === firstRank && file === "e") {
+ const fullRank = [];
+ for (let i = 1; i < 9; i++) {
+ fullRank.push(board.getPiece(firstRank, i));
+ }
+
+ const queenCorner = fullRank[0];
+ const kingCorner = fullRank[fullRank.length - 1];
+ // Queen-side castle
+ if (queenCorner && queenCorner.type === "rook" && queenCorner.color === this.color && !fullRank[1] && !fullRank[2] && !fullRank[3] && board.canCastle(this.color, true)) {
+
+ }
+
+ // King-side castle
+ if (kingCorner && kingCorner.type === "rook" && kingCorner.color === this.color && !fullRank[8 - 2] && !fullRank[8 - 3] && board.canCastle(this.color, false)) {
+
+ }
+ }
+ // // If kings and rooks have not moved
+ // if (this.color === "w" && rank === 1 && file === "e") {
+ // const fullRank = [];
+ // for (let i = 1; i < 9; i++) {
+ // fullRank.push(board.getPiece(1, i));
+ // };
+ // const queenCorner = fullRank[0];
+ // const kingCorner = fullRank[fullRank.length - 1];
+
+ // // Queen-side castle
+ // if (queenCorner && queenCorner.type === "rook" && queenCorner.color === "w" && !fullRank[1] && !fullRank[2] && !fullRank[3]) {
+
+ // }
+ // // King-side castle
+ // if (kingCorner && kingCorner.type === "rook" && kingCorner.color === "w" && !fullRank[8 - 2] && !fullRank[8 - 3]) {
+
+ // }
+ // }
+
+ // if (this.color === "b" && rank === 8 && file === "e") {
+ // const fullRank = [];
+ // for (let i = 1; i < 9; i++) {
+ // fullRank.push(board.getPiece(8, i));
+ // }
+ // const queenCorner = fullRank[0];
+ // const kingCorner = fullRank[fullRank.length - 1];
+
+ // // Queen-side castle
+
+ // // King-side castle
+ // }
+
+
+
+
if (opts?.validOnly) return this.removeKings(moves, board);
if (opts?.takesOnly) return this.removeNonTakes(moves, board);
diff --git a/src/chess/pages/Home.tsx b/src/chess/pages/Home.tsx
index d62de79..c7fe366 100644
--- a/src/chess/pages/Home.tsx
+++ b/src/chess/pages/Home.tsx
@@ -89,7 +89,7 @@ const Home: React.FC = () => {
h="37vh"
key={game._id}
>
-
+
{board.renderDisplay("3vh")}
diff --git a/src/chess/pages/Login.tsx b/src/chess/pages/Login.tsx
index 337c244..78a98e8 100644
--- a/src/chess/pages/Login.tsx
+++ b/src/chess/pages/Login.tsx
@@ -13,6 +13,7 @@ import {
Link,
Alert,
AlertIcon,
+ Box
} from "@chakra-ui/react";
import { Formik, Field, FormikProps } from "formik";
import { FaRegEyeSlash, FaRegEye } from "react-icons/fa";
@@ -43,7 +44,7 @@ const Login: React.FC = () => {
useEffect(() => {
if (!loading && !error && submitted) {
- if (loc.state.redirect) {
+ if (loc.state?.redirect) {
navigate(loc.state.redirect);
} else {
navigate("/chess/home");
@@ -60,6 +61,7 @@ const Login: React.FC = () => {
)}
+
{
)}
+
);
diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx
new file mode 100644
index 0000000..56032f1
--- /dev/null
+++ b/src/components/ColorPicker.tsx
@@ -0,0 +1,111 @@
+import React, { useState } from "react";
+import {
+ Popover,
+ PopoverTrigger,
+ Button,
+ PopoverContent,
+ PopoverArrow,
+ PopoverCloseButton,
+ PopoverHeader,
+ PopoverBody,
+ SimpleGrid,
+ Input,
+ LayoutProps,
+ BorderProps,
+ Center
+} from "@chakra-ui/react";
+
+export type ColorPickerProps = {
+ colors: string[],
+ defaultColor?: string,
+ onColorChange?: (color: string) => void,
+ height?: LayoutProps["height"],
+ width?: LayoutProps["width"],
+ borderRadius?: BorderProps["borderRadius"],
+ contentWidth?: LayoutProps["width"],
+ headerHeight?: LayoutProps["height"],
+ textColor?: string
+}
+
+const ColorPicker: React.FC = ({
+ colors,
+ defaultColor,
+ onColorChange,
+ height,
+ width,
+ borderRadius,
+ contentWidth,
+ headerHeight,
+ textColor = "white"
+}) => {
+
+ const [color, setColor] = useState(defaultColor ?? colors[0]);
+
+ const handleChange = (color: string) => {
+ setColor(color);
+ if (onColorChange) onColorChange(color);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {color}
+
+
+
+
+ {
+ colors.map(c => {
+ return (
+
+ handleChange(e.target.value)}
+ />
+
+
+
+ )
+};
+
+export default ColorPicker;
\ No newline at end of file
diff --git a/src/components/Page/NavBar.tsx b/src/components/Page/NavBar.tsx
index 0ccd639..38b3714 100644
--- a/src/components/Page/NavBar.tsx
+++ b/src/components/Page/NavBar.tsx
@@ -21,6 +21,7 @@ import {
faPen,
faUser,
faChessPawn,
+ faGamepad
} from "@fortawesome/free-solid-svg-icons";
import { styles } from "./styles/NavBar.styles";
@@ -97,7 +98,7 @@ const NavBar: React.FC = ({ active }) => {
Blog
- {process.env.NODE_ENV === "development" && (
+ {/* {process.env.NODE_ENV === "development" && (
= ({ active }) => {
Chess
+ )} */}
+ {process.env.NODE_ENV === "development" && (
+
+
+ Arcade
+
)}
{
+ const { vh } = viewport();
+
+ const height = vh * 0.8;
+ const width = vh * 0.8;
+ const ship_size = 20;
+
+ const [ship, setShip] = useStateRef(new Ship(ship_size, ship_size, { startPos: new Vec2(width / 2, height / 2)}));
+
+ const [asteroids, setAsteroids] = useStateRef([])
+ const [bullets, setBullets] = useStateRef([]);
+
+ for (let i = 0; i < 10; i++) {
+ let pos = Vec2.RandomInteger({ min: new Vec2(), max: new Vec2(width, height)});
+ const radius = randInt(10, 50);
+ let j = 0;
+ while (j < i) {
+ const dist = Vec2.Distance(asteroids.ref.current[j].pos, pos)
+ if (dist < (radius + asteroids.ref.current[j].radius + 10)) {
+ pos = Vec2.RandomInteger({ min: new Vec2(), max: new Vec2(width, height)})
+ j = 0;
+ } else {
+ j++
+ }
+ }
+ const ast = new Asteroid({ startPos: pos, radius: radius });
+ ast.vel = Vec2.Random();
+ ast.vel.setMagnitude(1);
+ asteroids.ref.current.push(ast);
+ }
+
+ const handleDraw = (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => {
+ renderer.clear();
+ renderer.rect(0, 0, ctx.canvas.width, ctx.canvas.height, { fill: "#000000" });
+ for (let i = 0; i < asteroids.ref.current.length; i++) {
+ asteroids.ref.current[i].edges(width, height);
+ asteroids.ref.current[i].update(asteroids.ref.current);
+ asteroids.ref.current[i].render(renderer);
+ }
+
+ for (let i = 0; i < bullets.ref.current.length; i++) {
+ bullets.ref.current[i].update();
+ bullets.ref.current[i].render(renderer);
+
+ for (let j = asteroids.ref.current.length - 1; j >= 0; j--) {
+ if (bullets.ref.current[i].hit(asteroids.ref.current[j])) {
+ const smallAsts = asteroids.ref.current[j].breakup();
+ if (smallAsts) {
+ asteroids.ref.current.push(...smallAsts);
+ }
+ asteroids.ref.current.splice(j, 1);
+ }
+ }
+ }
+
+ ship.ref.current.edges(width, height);
+ ship.ref.current.update();
+ ship.ref.current.render(renderer)
+ }
+
+ useEffect(() => {
+ const handleKeydown = (ev: KeyboardEvent) => {
+ ev.preventDefault();
+ const rightCodes = ["ArrowRight", "KeyD"]
+ const leftCodes = ["ArrowLeft", "KeyA"]
+ const upCodes = ["ArrowUp", "KeyW"]
+ const downCodes = ["ArrowDown", "KeyS"];
+
+ if (ev.code === "Space") {
+ const s = ship.ref.current;
+ const bullet = new Bullet(s.polygon.vertices[2].copy());
+ bullet.vel = Vec2.Add(s.vel.copy(), Vec2.FromAngle(ship.ref.current.heading).setMagnitude(2));
+ bullets.ref.current.push(bullet)
+ }
+
+ if (rightCodes.includes(ev.code)) {
+ ship.ref.current.heading += 0.4;
+ }
+
+ if (leftCodes.includes(ev.code)) {
+ ship.ref.current.heading -= 0.4;
+ }
+
+ if (upCodes.includes(ev.code)) {
+ ship.ref.current.acc = Vec2.FromAngle(ship.ref.current.heading);
+ }
+
+ if (downCodes.includes(ev.code)) {
+ ship.ref.current.acc = Vec2.FromAngle(ship.ref.current.heading).mult(-1);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeydown);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeydown)
+ }
+ }, [])
+
+ return (
+ <>
+
+ >
+ )
+}
+
+export default Asteroids;
\ No newline at end of file
diff --git a/src/games/Asteroids/utils/Asteroid.ts b/src/games/Asteroids/utils/Asteroid.ts
new file mode 100644
index 0000000..d2486a4
--- /dev/null
+++ b/src/games/Asteroids/utils/Asteroid.ts
@@ -0,0 +1,104 @@
+import { Vec2 } from "@website/games/utils/vec2";
+import { Polygon } from "@website/games/utils/polygon";
+import { CanvasRenderer } from "@website/games/utils/canvas";
+import { mapNumbers, randInt } from "@website/games/utils/math";
+
+
+type AsteroidParams = {
+ startPos?: Vec2,
+ radius?: number,
+ minOffset?: number,
+ maxOffset?: number
+}
+
+export class Asteroid {
+ public pos: Vec2 = new Vec2();
+ public vel: Vec2 = new Vec2();
+ public polygon: Polygon;
+ public points: number = randInt(5, 15);
+ public radius: number = randInt(10, 50);
+ public pointOffset: number[];
+
+ constructor(
+ opts?: AsteroidParams
+ ) {
+ if (opts?.startPos) {
+ this.pos = opts.startPos.copy();
+ }
+
+ if (opts?.radius) {
+ this.radius = opts.radius;
+ }
+
+
+ this.pointOffset = [];
+ for (let i = 0; i < this.points; i++) {
+ this.pointOffset.push(randInt(opts?.minOffset ?? -10, opts?.maxOffset ?? 10))
+ }
+
+ this.polygon = new Polygon(this.createVertices());
+ }
+
+ private createVertices = () => {
+ const vertices: Vec2[] = [];
+ for (let i = 0; i < this.points; i++) {
+ const angle = mapNumbers(i, 0, this.points, 0, Math.PI * 2);
+ const x = this.pos.x + ((this.radius + this.pointOffset[i]) * Math.cos(angle))
+ const y = this.pos.y + ((this.radius + this.pointOffset[i]) * Math.sin(angle));
+ vertices.push(new Vec2(x, y));
+ }
+ return vertices;
+ }
+
+ collision = (asteroids: Asteroid[]) => {
+ for (let i = 0; i < asteroids.length; i++) {
+ if (Polygon.doIntersect(this.polygon, asteroids[i].polygon) && asteroids[i] !== this) {
+ // Elastic collision with m2 = x * m1
+ let x = asteroids[i].radius / this.radius
+ const v1 = this.vel.copy();
+ const v2 = asteroids[i].vel.copy();
+ const v1Prime = v1.copy().mult((1 - x)).add(v2.copy().mult(2 * x)).mult(1 / (x + 1));
+ const v2Prime = v1.copy().mult(2).add(v2.copy().mult(x - 1)).mult(1 / (x + 1));
+
+ this.vel = v1Prime.copy();
+ asteroids[i].vel = v2Prime.copy();
+ }
+ }
+ }
+
+ edges = (width: number, height: number) => {
+ if (this.pos.x > width + this.radius) {
+ this.pos.x = this.radius;
+ } else if (this.pos.x < -this.radius) {
+ this.pos.x = width + this.radius;
+ }
+
+ if (this.pos.y > height + this.radius) {
+ this.pos.y = this.radius;
+ } else if (this.pos.y < -this.radius) {
+ this.pos.y = height + this.radius;
+ }
+ }
+
+ render = (renderer: CanvasRenderer) => {
+ renderer.polygon(this.polygon, { stroke: "#ffffff" });
+ }
+
+ update = (asteroids: Asteroid[]) => {
+ // this.collision(asteroids)
+ this.pos.add(this.vel);
+ this.polygon = new Polygon(this.createVertices())
+ }
+
+ breakup = () => {
+ if (this.radius / 2 < 10) {
+ return;
+ }
+ const newRad = Math.floor(this.radius / 2);
+ const one = new Asteroid({ startPos: this.pos.copy(), radius: newRad, minOffset: -newRad, maxOffset: newRad });
+ const two = new Asteroid({ startPos: this.pos.copy(), radius: newRad, minOffset: -newRad, maxOffset: newRad });
+ one.vel = new Vec2(this.vel.y, -this.vel.x);
+ two.vel = new Vec2(-this.vel.y, this.vel.x);
+ return [one, two];
+ }
+}
\ No newline at end of file
diff --git a/src/games/Asteroids/utils/Bullet.ts b/src/games/Asteroids/utils/Bullet.ts
new file mode 100644
index 0000000..12646d8
--- /dev/null
+++ b/src/games/Asteroids/utils/Bullet.ts
@@ -0,0 +1,24 @@
+import { CanvasRenderer } from "@website/games/utils/canvas";
+import { Vec2 } from "@website/games/utils/vec2";
+import { Asteroid } from "./Asteroid";
+
+
+export class Bullet {
+ public vel: Vec2 = new Vec2();
+ constructor(
+ public pos: Vec2
+ ) {}
+
+ render = (renderer: CanvasRenderer) => {
+ renderer.circle(this.pos.x, this.pos.y, 1, 0, Math.PI * 2, { fill: "#ffffff" });
+ }
+
+ update = () => {
+ this.pos.add(this.vel);
+ }
+
+ hit = (asteroid: Asteroid) => {
+ const dist = Vec2.Distance(this.pos, asteroid.pos);
+ return dist < asteroid.radius;
+ }
+}
\ No newline at end of file
diff --git a/src/games/Asteroids/utils/Ship.ts b/src/games/Asteroids/utils/Ship.ts
new file mode 100644
index 0000000..0be28cb
--- /dev/null
+++ b/src/games/Asteroids/utils/Ship.ts
@@ -0,0 +1,63 @@
+import { Vec2 } from "@website/games/utils/vec2";
+import { Polygon } from "@website/games/utils/polygon";
+import { CanvasRenderer } from "@website/games/utils/canvas";
+
+type ShipParams = {
+ startPos?: Vec2
+}
+
+export class Ship {
+ public pos: Vec2 = new Vec2();
+ public vel: Vec2 = new Vec2();
+ public acc: Vec2 = new Vec2();
+ public heading: number = 0;
+ public polygon: Polygon;
+
+ constructor(
+ public width: number,
+ public height: number,
+ opts?: ShipParams
+ ) {
+ if (opts?.startPos) {
+ this.pos = opts.startPos.copy()
+ }
+ this.polygon = new Polygon(this.createVertices())
+ }
+
+ private createVertices = () => {
+ return [
+ this.pos.copy(),
+ new Vec2(this.pos.x + (this.width / 2), this.pos.y + (this.height / 3)),
+ new Vec2(this.pos.x, this.pos.y - ((2 * this.height) / 3)),
+ new Vec2(this.pos.x - (this.width / 2), this.pos.y + (this.height / 3))
+ ]
+ }
+
+ edges = (width: number, height: number) => {
+ if (this.pos.x > width) {
+ this.pos.x = 0;
+ } else if (this.pos.x < 0) {
+ this.pos.x = width;
+ }
+
+ if (this.pos.y > height) {
+ this.pos.y = 0;
+ } else if (this.pos.y < 0) {
+ this.pos.y = height;
+ }
+ }
+
+ render = (renderer: CanvasRenderer) => {
+ this.polygon.rotate(this.heading + (Math.PI / 2));
+ renderer.polygon(this.polygon, { stroke: "#ffffff" })
+ }
+
+ update = () => {
+ this.pos.add(this.vel);
+ this.vel.add(this.acc);
+ this.vel.mult(0.99);
+ this.acc.mult(0);
+
+ this.polygon = new Polygon(this.createVertices());
+ }
+}
\ No newline at end of file
diff --git a/src/games/Canvas.tsx b/src/games/Canvas.tsx
new file mode 100644
index 0000000..0d3ad2f
--- /dev/null
+++ b/src/games/Canvas.tsx
@@ -0,0 +1,67 @@
+import React, { useRef, useEffect } from "react";
+import { CanvasRenderer } from "./utils/canvas";
+
+type CanvasProps = {
+ onDraw?: (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => void,
+ height?: number,
+ width?: number,
+ fps?: number
+}
+
+const Canvas: React.FC = ({
+ onDraw = (ctx, frame) => {},
+ height = 100,
+ width = 100,
+ fps = 30
+}) => {
+
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current as HTMLCanvasElement;
+ const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+ const renderer = new CanvasRenderer(context);
+
+ let frame = 0
+ let animID: number;
+ let fpsInterval = 1000 / fps;
+ let then = window.performance.now();
+ let startTime = then;
+ let now, elapsed;
+
+ const render = (dt: number = 0) => {
+
+ frame++
+ onDraw(context, renderer, frame);
+ animID = window.requestAnimationFrame(render);
+ // animID = window.requestAnimationFrame(render);
+ // now = dt;
+ // elapsed = now - then;
+ // if (elapsed > fpsInterval) {
+ // then = now - (elapsed % fpsInterval)
+ // frame++
+ // onDraw(context, frame);
+ // }
+ // if (fps) {
+ // setTimeout(() => {
+ // animID = window.requestAnimationFrame(render);
+ // }, 1000 / fps)
+ // } else {
+ // animID = window.requestAnimationFrame(render);
+ // }
+
+ }
+
+ render()
+
+ return () => {
+ window.cancelAnimationFrame(animID)
+ }
+ }, [onDraw, width, height])
+
+ return (
+
+ )
+}
+
+export default Canvas;
\ No newline at end of file
diff --git a/src/games/CirclePacking/assets/dog.jpeg b/src/games/CirclePacking/assets/dog.jpeg
new file mode 100644
index 0000000..f91154d
Binary files /dev/null and b/src/games/CirclePacking/assets/dog.jpeg differ
diff --git a/src/games/CirclePacking/assets/mountain-sunset.jpeg b/src/games/CirclePacking/assets/mountain-sunset.jpeg
new file mode 100644
index 0000000..0926dee
Binary files /dev/null and b/src/games/CirclePacking/assets/mountain-sunset.jpeg differ
diff --git a/src/games/CirclePacking/index.tsx b/src/games/CirclePacking/index.tsx
new file mode 100644
index 0000000..8693403
--- /dev/null
+++ b/src/games/CirclePacking/index.tsx
@@ -0,0 +1,281 @@
+import React, { useRef, useState } from "react";
+import Canvas from "../Canvas";
+import {
+ Button,
+ useToken,
+ VStack,
+ Text,
+ Input,
+ SimpleGrid,
+ Image,
+ HStack,
+ Box,
+ Icon
+} from "@chakra-ui/react";
+import { FaUpload, FaPlay, FaStop, FaRedo } from "react-icons/fa"
+import { clearCanvas, CanvasRenderer } from "../utils/canvas";
+import { Vec2 } from "../utils/vec2";
+import { PixelImage } from "./utils/PixelImage";
+import { Circle } from "./utils/Circle";
+import { randInt } from "../utils/math";
+import { useStateRef } from "../utils/hooks";
+import dog from "./assets/dog.jpeg";
+import mountain from "./assets/mountain-sunset.jpeg";
+import { styles } from "./styles";
+
+
+
+const CirclePacking: React.FC = () => {
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
+ const [started, setStarted] = useStateRef(false);
+ const [ gray800 ] = useToken("colors", ["gray.800"])
+ const colorNums = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+ const purples = useToken("colors", colorNums.map( n => `brand.purple.${n}00`));
+ const blues = useToken("colors", colorNums.map( n => `brand.blue.${n}00`));
+ const fileInputRef = useRef(null);
+ const [selectedImage, setSelectedImage] = useState(null);
+ const selectedImageRef = useRef(null);
+
+ const [circles, setCircles] = useStateRef([]);
+ const [maxSize, setMaxSize] = useStateRef(5);
+ const [pixelImage, setPixelImage] = useStateRef(null);
+ const [completion, setCompletion] = useStateRef(0);
+
+ // Adds circle to render list
+ const addCircle = (ctx: CanvasRenderingContext2D) => {
+ // Random new position
+ let newPos = Vec2.RandomInteger({ min: new Vec2(), max: new Vec2(ctx.canvas.width - 1, ctx.canvas.height - 1)});
+
+ // Check if new position is valid
+ let i = 0
+ while (i < circles.ref.current.length) {
+ // If new position is the same as any other position or inside another circle
+ const curr = circles.ref.current[i];
+ if (curr.position.equals(newPos) || curr.isInside(newPos)) {
+ // Change the position and start again
+ newPos = Vec2.RandomInteger({ min: new Vec2(), max: new Vec2(ctx.canvas.width - 1, ctx.canvas.height - 1)});
+ i = 0;
+ }
+ i++
+ }
+ // Selecting random color between blue and purple
+
+ let color: string;
+ if (pixelImage.ref.current) {
+ color = pixelImage.ref.current.image[newPos.y][newPos.x].hex;
+ } else {
+ if (randInt(0, 1) === 0) {
+ color = purples[randInt(0, purples.length - 1)]
+ } else {
+ color = blues[randInt(0, blues.length - 1)]
+ }
+ }
+
+
+ // Add new circle with size 1
+ setCircles( prev => [...prev, new Circle(newPos, 1, color)]);
+ }
+
+ // Calculate total area of circles
+ const totalArea = () => {
+ let tot = 0;
+ for (let i = 0; i < circles.ref.current.length; i++) {
+ const curr = circles.ref.current[i];
+ tot += Math.PI * curr.size * curr.size;
+ }
+ return tot;
+ }
+
+ // Increases the size of circles
+ const growCircles = () => {
+ // Iterate over every circle
+ const currCircles = circles.ref.current;
+ for (let i = 0; i < currCircles.length; i++) {
+ const curr = currCircles[i];
+ if (curr.stopGrowing || curr.size >= maxSize.ref.current) continue; // If this circle has hit another move to next
+ // Iterate through all circles again
+ for (let j = 0; j < currCircles.length; j++) {
+ if (j !== i) {
+ const checker = currCircles[j];
+ // If the new size of the circe will intersect, stop growing
+ if (checker.isInside(curr.position, curr.size + 0.5)) {
+ setCircles(prev => {
+ const copy = [...prev];
+ copy[i].stopGrowing = true;
+ return copy;
+ })
+ }
+ }
+ }
+ if (!circles.ref.current[i].stopGrowing) {
+ // Increase the size
+ setCircles( prev => {
+ const copy = [...prev];
+ copy[i].size += 0.5;
+ return copy;
+ })
+ }
+ }
+ }
+
+ const renderCircles = (renderer: CanvasRenderer) => {
+ for (let i = 0; i < circles.ref.current.length; i++) {
+ const { position, size, color } = circles.ref.current[i]
+ renderer.circle(position.x, position.y, size, 0, 2 * Math.PI, { fill: color });
+ }
+ }
+
+ const handleDraw = (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => {
+ clearCanvas(ctx);
+ renderer.rect(0, 0, ctx.canvas.width, ctx.canvas.height, { fill: gray800 });
+ const area = ctx.canvas.width * ctx.canvas.height;
+ if (started.ref.current && totalArea() < (area * 0.65)) {
+ console.log("running...");
+ setCompletion(totalArea() / (area * 0.65));
+ addCircle(ctx);
+ growCircles();
+ }
+ renderCircles(renderer);
+ };
+
+ const handleReset = () => {
+ setCircles([]);
+ setStarted(false);
+ setCompletion(0);
+ }
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ e.preventDefault();
+ if (e.target.files) {
+ console.log(e.target.files[0]);
+ const imageUrl = URL.createObjectURL(e.target.files[0])
+ setSelectedImage(imageUrl);
+ }
+ }
+
+ const handleImageCanvasDraw = (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => {
+ renderer.clear();
+ if (selectedImageRef.current) {
+ const canvas = ctx.canvas;
+ const img = selectedImageRef.current;
+ const scaleFactor = Math.max(canvas.width / img.width, canvas.height / img.height);
+ const newWidth = img.width * scaleFactor;
+ const newHeight = img.height * scaleFactor;
+
+ const x = (canvas.width / 2) - (newWidth / 2);
+ const y = (canvas.height / 2) - (newHeight / 2);
+ ctx.drawImage(img, x, y, newWidth, newHeight);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ setPixelImage(new PixelImage(imageData.data, canvas.width));
+ }
+ }
+
+ const canStart = () => {
+ if (selectedImage && !started) {
+ return true;
+ }
+
+ return false;
+ }
+
+ const width = vh * 0.5;
+ const height = vh * 0.5;
+
+ return (
+ <>
+
+
+ How does this work?
+ Upload an image of your choice or choose one of the images below.
+ Note: Images with less detail will work better.
+ Click start to see the magic happen in front of your eyes.
+
+
+ {
+ if (fileInputRef.current) fileInputRef.current.click();
+ }}
+ >
+
+
+
+ setSelectedImage(dog)}
+ />
+ setSelectedImage(mountain)}
+ />
+
+
+
+ Image:
+ {
+ selectedImage && (
+ <>
+
+
+ >
+
+ )
+ }
+
+
+ Circle Packed Image:
+
+
+
+
+ Completion:
+
+
+
+
+
+
+ }
+ variant="gradient"
+ >Reset
+
+
+
+ >
+ )
+};
+
+export default CirclePacking;
\ No newline at end of file
diff --git a/src/games/CirclePacking/styles.ts b/src/games/CirclePacking/styles.ts
new file mode 100644
index 0000000..10bc900
--- /dev/null
+++ b/src/games/CirclePacking/styles.ts
@@ -0,0 +1,46 @@
+import { BoxProps, IconProps, ImageProps, InputProps } from "@chakra-ui/react";
+
+
+const fileUploadBox: BoxProps = {
+ h: "10vw",
+ w: "10vw",
+ borderWidth: "1px",
+ borderStyle: "dashed",
+ borderColor: "white",
+ pos: "relative"
+}
+
+export const fileInput: InputProps = {
+ type: "file",
+ accept: "image/*",
+ p: "0",
+ h: "auto",
+ hidden: true
+}
+
+export const fileUploadIcon: IconProps = {
+ h:"2.5vw",
+ w:"2.5vw",
+ pos: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)"
+}
+
+export const defaultImage: ImageProps = {
+ w: "10vw",
+ h: "10vw",
+ objectFit: "cover",
+ opacity: 0.5,
+ _hover: {
+ opacity: 1,
+ cursor: "pointer"
+ }
+}
+
+export const styles = {
+ fileUploadBox,
+ fileInput,
+ fileUploadIcon,
+ defaultImage
+}
\ No newline at end of file
diff --git a/src/games/CirclePacking/utils/Circle.ts b/src/games/CirclePacking/utils/Circle.ts
new file mode 100644
index 0000000..47e75ad
--- /dev/null
+++ b/src/games/CirclePacking/utils/Circle.ts
@@ -0,0 +1,15 @@
+import { Vec2 } from "@website/games/utils/vec2";
+
+export class Circle{
+ public stopGrowing: boolean = false;
+ constructor(
+ public position: Vec2,
+ public size: number = 1,
+ public color: string = "#ff0000"
+ ){}
+
+ public isInside = (pos: Vec2, radius: number = 0) => {
+ const dist = Vec2.Distance(pos, this.position);
+ return dist <= (this.size + radius);
+ }
+}
\ No newline at end of file
diff --git a/src/games/CirclePacking/utils/PixelImage.ts b/src/games/CirclePacking/utils/PixelImage.ts
new file mode 100644
index 0000000..2004a01
--- /dev/null
+++ b/src/games/CirclePacking/utils/PixelImage.ts
@@ -0,0 +1,40 @@
+
+export class RGBA{
+ constructor(
+ public r: number,
+ public g: number,
+ public b: number,
+ public a: number
+ ) {}
+
+ private toHex(c: number) {
+ const hex = c.toString(16);
+ return hex.length === 1 ? "0" + hex : hex;
+ }
+ get hex() {
+ return "#" + this.toHex(this.r) + this.toHex(this.g) + this.toHex(this.b) + this.toHex(this.a)
+ }
+}
+
+export class PixelImage{
+ public image: RGBA[][]
+ constructor(
+ data: Uint8ClampedArray,
+ width: number
+ ) {
+ this.image = [];
+ let temp: RGBA[] = [];
+ for (let i = 0; i < data.length; i += 4) {
+ const r = data[i];
+ const g = data[i + 1];
+ const b = data[i + 2];
+ const a = data[i + 3];
+ temp.push(new RGBA(r, g, b, a))
+ if (temp.length === width) {
+ this.image.push(temp);
+ temp = [];
+ }
+ }
+ // console.log(this.image);
+ }
+}
\ No newline at end of file
diff --git a/src/games/Flocking/index.tsx b/src/games/Flocking/index.tsx
new file mode 100644
index 0000000..6babb90
--- /dev/null
+++ b/src/games/Flocking/index.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import Canvas from "../Canvas";
+import { CanvasRenderer } from "../utils/canvas";
+import { viewport } from "../utils/math";
+import { Boid } from "./utils/Boid";
+import { Vec2 } from "../utils/vec2";
+
+const Flocking: React.FC = () => {
+ const { vh } = viewport();
+ const height = vh * 0.8;
+ const width = vh * 0.8;
+
+ const flock: Boid[] = [];
+ for (let i = 0; i < 500; i++) {
+ const pos = Vec2.RandomInteger({ max: new Vec2(width, height)});
+ const boid = new Boid(20, 20, { startPos: pos, perceptionRadius: 50, maxForce: 1, sepMult: 1.1, });
+ boid.vel = Vec2.Random();
+ boid.vel.randScale(1, 4);
+ flock.push(boid);
+ }
+
+ const handleDraw = (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => {
+ renderer.clear();
+ renderer.rect(0, 0, ctx.canvas.width, ctx.canvas.height, { fill: "#000000" })
+ for (let i = 0; i < flock.length; i++) {
+ flock[i].flocking(flock);
+ flock[i].edges(width, height);
+ flock[i].update();
+ flock[i].show(renderer);
+ }
+ }
+
+ return (
+ <>
+
+ >
+ )
+}
+
+export default Flocking;
\ No newline at end of file
diff --git a/src/games/Flocking/utils/Boid.ts b/src/games/Flocking/utils/Boid.ts
new file mode 100644
index 0000000..6d3516b
--- /dev/null
+++ b/src/games/Flocking/utils/Boid.ts
@@ -0,0 +1,201 @@
+import { CanvasRenderer } from "@website/games/utils/canvas";
+import { Polygon } from "@website/games/utils/polygon";
+import { Vec2 } from "@website/games/utils/vec2";
+
+type BoidOpts = {
+ startPos?: Vec2
+ maxSeeAhead?: number,
+ maxForce?: number,
+ maxSpeed?: number,
+ perceptionRadius?: number,
+ sepMult?: number,
+ alignMult?: number,
+ cohMult?: number
+}
+
+export class Boid {
+ public pos: Vec2 = new Vec2();
+ public vel: Vec2 = new Vec2();
+ public acc: Vec2 = new Vec2();
+ public ahead: Vec2 = new Vec2();
+ public polygon: Polygon;
+
+ public maxSeeAhead = .5;
+ public maxForce = 2;
+ public maxSpeed = 4;
+ public perceptionRadius = 50;
+ public separationMultiplier = 1;
+ public alignmentMultiplier = 1;
+ public cohesionMultiplier = 1;
+ constructor(public height: number, public width: number, opts?: BoidOpts) {
+ if (opts?.startPos) {
+ this.pos = opts.startPos.copy();
+ }
+
+ if (opts?.maxSeeAhead) {
+ this.maxSeeAhead = opts.maxSeeAhead;
+ }
+
+ if (opts?.maxForce) {
+ this.maxForce = opts.maxForce
+ }
+
+ if (opts?.maxSpeed) {
+ this.maxSpeed = opts.maxSpeed;
+ }
+
+ if (opts?.perceptionRadius) {
+ this.perceptionRadius = opts.perceptionRadius;
+ }
+
+ if (opts?.sepMult) {
+ this.separationMultiplier = opts.sepMult;
+ }
+
+ if (opts?.cohMult) {
+ this.cohesionMultiplier = opts.cohMult;
+ }
+
+ if (opts?.alignMult) {
+ this.alignmentMultiplier = opts.alignMult;
+ }
+
+
+ this.polygon = new Polygon(this.createVertices())
+
+ }
+
+ private createVertices = () => {
+ return [
+ this.pos.copy(),
+ new Vec2(this.pos.x + (this.width / 2), this.pos.y + (this.height / 3)),
+ new Vec2(this.pos.x, this.pos.y - ((2 * this.height) / 3)),
+ new Vec2(this.pos.x - (this.width / 2), this.pos.y + (this.height / 3))
+ ]
+ }
+
+ private align = (boids: Boid[]): Vec2 => {
+ let total = 0;
+ let steering = new Vec2();
+
+ for (let i = 0; i < boids.length; i++) {
+ const distance = Vec2.Distance(this.pos, boids[i].pos);
+ if (boids[i] !== this && distance < this.perceptionRadius) {
+ steering.add(boids[i].vel);
+ total++
+ }
+ }
+
+ if (total > 0) {
+ steering.mult(1/total);
+ steering.setMagnitude(this.maxSpeed);
+ steering.sub(this.vel);
+ steering.limit(this.maxForce);
+ }
+
+ return steering;
+ }
+
+ private cohesion = (boids: Boid[]): Vec2 => {
+ let total = 0;
+ let steering = new Vec2();
+
+ for (let i = 0; i < boids.length; i++) {
+ const distance = Vec2.Distance(this.pos, boids[i].pos);
+ if (boids[i] !== this && distance < this.perceptionRadius) {
+ steering.add(boids[i].pos);
+ total++
+ }
+ }
+
+ if (total > 0) {
+ steering.mult(1 / total);
+ steering.sub(this.pos);
+ steering.setMagnitude(this.maxSpeed);
+ steering.sub(this.vel);
+ steering.limit(this.maxForce);
+ }
+
+ return steering;
+ }
+
+ private separation = (boids: Boid[]): Vec2 => {
+ let total = 0;
+ let steering = new Vec2();
+
+ for (let i = 0; i < boids.length; i++) {
+ const distance = Vec2.Distance(this.pos, boids[i].pos);
+ if (boids[i] !== this && distance < this.perceptionRadius) {
+ const diff = Vec2.Subtract(boids[i].pos, this.pos);
+ diff.mult(1/(distance * distance));
+ steering.add(diff);
+ total++
+ }
+ }
+
+ if (total > 0) {
+ steering.mult(1 / total);
+ steering.setMagnitude(this.maxSpeed);
+ steering.sub(this.vel);
+ steering.limit(this.maxForce);
+ }
+
+ return steering;
+ }
+
+ public intersection = (boids: Boid[]) => {
+ for (let i = 0; i < boids.length; i++) {
+ if (boids[i] !== this) {
+ if (Polygon.doIntersect(this.polygon, boids[i].polygon)) {
+ this.acc.mult(-1).limit(this.maxForce)
+ boids[i].acc.mult(-1).limit(this.maxForce);
+ }
+ }
+ }
+ }
+
+ public flocking = (boids: Boid[]) => {
+ this.acc.mult(0);
+ const ali = this.align(boids);
+ const coh = this.cohesion(boids);
+ const sep = this.separation(boids);
+
+ ali.mult(this.alignmentMultiplier);
+ coh.mult(this.cohesionMultiplier);
+ sep.mult(this.separationMultiplier);
+
+ this.acc.add(ali);
+ this.acc.add(coh);
+ this.acc.add(sep);
+ // this.intersection(boids);
+ }
+
+ public edges = (width: number, height: number) => {
+ if (this.pos.x > width) {
+ this.pos.x = 0;
+ } else if (this.pos.x < 0) {
+ this.pos.x = width;
+ }
+
+ if (this.pos.y > height) {
+ this.pos.y = 0;
+ } else if (this.pos.y < 0) {
+ this.pos.y = height;
+ }
+ }
+
+ public show = (renderer: CanvasRenderer) => {
+ renderer.polygon(this.polygon, { fill: "#ffffff" });
+ }
+
+
+ public update = () => {
+ this.pos.add(this.vel);
+ this.vel.add(this.acc);
+ this.polygon = new Polygon(this.createVertices())
+ this.polygon.rotate(this.vel.heading() + (Math.PI / 2));
+
+ this.ahead = Vec2.Add(this.pos, this.vel);
+ this.ahead.mult(this.maxSeeAhead);
+ }
+}
\ No newline at end of file
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_1.png b/src/games/GeneticMissiles/assets/explosion/Explosion_1.png
new file mode 100644
index 0000000..96cb745
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_1.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_10.png b/src/games/GeneticMissiles/assets/explosion/Explosion_10.png
new file mode 100644
index 0000000..366d5a5
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_10.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_2.png b/src/games/GeneticMissiles/assets/explosion/Explosion_2.png
new file mode 100644
index 0000000..faaab7f
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_2.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_3.png b/src/games/GeneticMissiles/assets/explosion/Explosion_3.png
new file mode 100644
index 0000000..8c8792b
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_3.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_4.png b/src/games/GeneticMissiles/assets/explosion/Explosion_4.png
new file mode 100644
index 0000000..e746841
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_4.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_5.png b/src/games/GeneticMissiles/assets/explosion/Explosion_5.png
new file mode 100644
index 0000000..c408b50
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_5.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_6.png b/src/games/GeneticMissiles/assets/explosion/Explosion_6.png
new file mode 100644
index 0000000..0070fd3
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_6.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_7.png b/src/games/GeneticMissiles/assets/explosion/Explosion_7.png
new file mode 100644
index 0000000..1f1b120
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_7.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_8.png b/src/games/GeneticMissiles/assets/explosion/Explosion_8.png
new file mode 100644
index 0000000..02daca0
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_8.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/Explosion_9.png b/src/games/GeneticMissiles/assets/explosion/Explosion_9.png
new file mode 100644
index 0000000..4269364
Binary files /dev/null and b/src/games/GeneticMissiles/assets/explosion/Explosion_9.png differ
diff --git a/src/games/GeneticMissiles/assets/explosion/index.ts b/src/games/GeneticMissiles/assets/explosion/index.ts
new file mode 100644
index 0000000..b74175b
--- /dev/null
+++ b/src/games/GeneticMissiles/assets/explosion/index.ts
@@ -0,0 +1,12 @@
+import e1 from "./Explosion_1.png";
+import e2 from "./Explosion_2.png";
+import e3 from "./Explosion_3.png";
+import e4 from "./Explosion_4.png";
+import e5 from "./Explosion_5.png"
+import e6 from "./Explosion_6.png"
+import e7 from "./Explosion_7.png"
+import e8 from "./Explosion_8.png"
+import e9 from "./Explosion_9.png"
+import e10 from "./Explosion_10.png"
+
+export const explosion_sources = [e1, e2, e3, e4, e5, e6, e7, e8, e9, e10];
\ No newline at end of file
diff --git a/src/games/GeneticMissiles/assets/missile-dead.png b/src/games/GeneticMissiles/assets/missile-dead.png
new file mode 100644
index 0000000..b0b5571
Binary files /dev/null and b/src/games/GeneticMissiles/assets/missile-dead.png differ
diff --git a/src/games/GeneticMissiles/assets/missile-sprite.png b/src/games/GeneticMissiles/assets/missile-sprite.png
new file mode 100644
index 0000000..30d1ec7
Binary files /dev/null and b/src/games/GeneticMissiles/assets/missile-sprite.png differ
diff --git a/src/games/GeneticMissiles/assets/sky.jpeg b/src/games/GeneticMissiles/assets/sky.jpeg
new file mode 100644
index 0000000..5a13616
Binary files /dev/null and b/src/games/GeneticMissiles/assets/sky.jpeg differ
diff --git a/src/games/GeneticMissiles/assets/skyscrapers/skyscrapers1.png b/src/games/GeneticMissiles/assets/skyscrapers/skyscrapers1.png
new file mode 100644
index 0000000..8d212a6
Binary files /dev/null and b/src/games/GeneticMissiles/assets/skyscrapers/skyscrapers1.png differ
diff --git a/src/games/GeneticMissiles/assets/skyscrapers/skyscrapers2.png b/src/games/GeneticMissiles/assets/skyscrapers/skyscrapers2.png
new file mode 100644
index 0000000..57d68fc
Binary files /dev/null and b/src/games/GeneticMissiles/assets/skyscrapers/skyscrapers2.png differ
diff --git a/src/games/GeneticMissiles/assets/target.png b/src/games/GeneticMissiles/assets/target.png
new file mode 100644
index 0000000..32ae0ab
Binary files /dev/null and b/src/games/GeneticMissiles/assets/target.png differ
diff --git a/src/games/GeneticMissiles/index.tsx b/src/games/GeneticMissiles/index.tsx
new file mode 100644
index 0000000..c6d3f40
--- /dev/null
+++ b/src/games/GeneticMissiles/index.tsx
@@ -0,0 +1,157 @@
+import React, { useRef, useState } from "react";
+import Canvas from "../Canvas";
+import { useStateRef, useImage } from "../utils/hooks";
+import { Text, HStack, Box, VStack, TextProps } from "@chakra-ui/react";
+import { Population } from "./utils/Population";
+import { CanvasRenderer } from "../utils/canvas";
+import { viewport } from "../utils/math";
+import { Vec2 } from "../utils/vec2";
+import { Polygon } from "../utils/polygon";
+import missile from "./assets/missile-sprite.png";
+import missileDead from "./assets/missile-dead.png";
+import { explosion_sources } from "./assets/explosion";
+import targetImage from "./assets/target.png";
+import sky from "./assets/sky.jpeg";
+import skyscr1 from "./assets/skyscrapers/skyscrapers1.png";
+import skyscr2 from "./assets/skyscrapers/skyscrapers2.png";
+
+const SmartFish: React.FC = () => {
+ const { vh } = viewport();
+ const width = 640 - 32;
+
+ const ASPECT_RATIO = 625 / 291;
+ const SKYSCR_AR = 123 / 62;
+ const missileSize = new Vec2(20, 20 * ASPECT_RATIO);
+ const height = vh * 0.6;
+ const targetSize = 40;
+ const sprite = useImage(missile);
+ const dead = useImage(missileDead);
+ const explosions = useImage(explosion_sources);
+ const startPos = useRef(new Vec2(missileSize.y + 25, height / 2))
+ const target = new Vec2(width - (targetSize * 2) - 10, height / 2);
+ const targetImg = useImage(targetImage);
+ const skyImg = useImage(sky);
+ const skyScrapers = useImage([skyscr1, skyscr2]);
+ const lifespan = 500;
+ const [geneCount, setGeneCount] = useStateRef(0);
+
+ const bounds = {
+ min: new Vec2(),
+ max: new Vec2(width, height)
+ }
+
+
+ const obstacles: { pos: Vec2, size: Vec2}[] = [
+ {
+ pos: new Vec2(width / 4, height - 200),
+ size: new Vec2(200 / SKYSCR_AR, 200)
+ },
+ {
+ pos: new Vec2((width / 4) + (200 / SKYSCR_AR) + 20, height - 300),
+ size: new Vec2(200 / SKYSCR_AR, 300)
+ },
+ ]
+
+ const [pop, setPop] = useStateRef(new Population(25, [missileSize.x, missileSize.y, lifespan, bounds, target, startPos.current, obstacles, targetSize]))
+ const [successful, setSuccessful] = useState(0);
+ const [generation, setGeneration] = useState(0);
+
+ const textStyles: Record = {
+ bold: {
+ as: "span",
+ fontWeight: "bold"
+ }
+ }
+
+ const handleDraw = (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => {
+ renderer.clear();
+ if (skyImg.current) {
+ renderer.image(skyImg.current, 0, 0, { width: ctx.canvas.width, height: ctx.canvas.height })
+ } else {
+ renderer.rect(0, 0, ctx.canvas.width, ctx.canvas.height, { fill: "#000000" });
+ }
+ if (targetImg.current) {
+ renderer.image(targetImg.current, target.x - (targetSize), target.y - (targetSize), { width: targetSize * 2, height: targetSize * 2 });
+ } else {
+ renderer.circle(target.x, target.y, 40, 0, 2 * Math.PI, { fill: "#00ff00" });
+ }
+
+
+ for (let i = 0; i < obstacles.length; i++) {
+ const { pos, size } = obstacles[i]
+ if (!!skyScrapers.current.length) {
+ renderer.image(skyScrapers.current[i], pos.x, pos.y, { width: size.x, height: size.y });
+ } else {
+ renderer.polygon(Polygon.fromRectangle(pos.x, pos.y, size.x, size.y), { fill: "#ff0000" });
+ }
+ }
+
+ if (!!sprite.current) {
+ pop.ref.current.run(renderer, sprite.current, geneCount.ref.current, dead.current, explosions.current);
+ setGeneCount(prev => prev + 1);
+ if (geneCount.ref.current === lifespan || pop.ref.current.allDead()) {
+ const success = pop.ref.current.evaluate(target);
+ pop.ref.current.selection();
+ setSuccessful(success);
+ setGeneCount(0);
+ setGeneration(prev => prev + 1);
+ }
+ }
+ }
+
+
+ return (
+ <>
+
+
+ On Target in Last Generation: {successful}
+ Generation Count: {generation}
+ Population Size: {25}
+
+
+
+
+
+
+ What is this?
+
+ In short, this is an "evolution" simulator. In computer science terms, this would be classified as a meta-heuristics algorithm simulation or, in other words, a genetic algorithm simulation.
+
+
+ To put it simply, this algorithm simulation displays "survival of the fittest" in a simplified manner.
+
+ How does it work?
+
+ We have a population of Missiles which have some randomly generated DNA. This DNA contains a randomly generated list of Vectors representing the thrust force's that will be applied to that Missile.
+
+
+ On each run of a population (generation), the missiles have their DNA thrust forces applied to them and at the end of their journey, the distance to the target is calculated. From this, a fitness value is calulated (higher if it is closer to the target, lower if further away). Once the entire population has completed, a new generation is created by choosing 2 parents and crossing over their DNA (with some probability of mutation) to create a child which is added to the new population. Missiles with higher fitness values have a higher chance of being selected as parents multiple times.
+
+
+ From this, over many generations, the missiles get more accurate.
+
+
+ >
+ )
+};
+
+export default SmartFish;
\ No newline at end of file
diff --git a/src/games/GeneticMissiles/utils/DNA.ts b/src/games/GeneticMissiles/utils/DNA.ts
new file mode 100644
index 0000000..220531d
--- /dev/null
+++ b/src/games/GeneticMissiles/utils/DNA.ts
@@ -0,0 +1,58 @@
+import { Vec2 } from "@website/games/utils/vec2";
+
+export class DNA {
+ public genes: Vec2[] = [];
+ public size: number;
+ constructor(
+ dna: number | DNA
+ ){
+ if (typeof dna === "number") {
+ this.size = dna;
+ for (let i = 0; i < dna; i++) {
+ this.genes.push(Vec2.Random());
+ }
+ } else {
+ this.genes = [...dna.genes];
+ this.size = dna.genes.length;
+ }
+
+ }
+
+ public copy = () => {
+ const copy = new DNA(0);
+ copy.size = this.size;
+ for (let i = 0; i < this.size; i++) {
+ copy.genes.push(this.genes[i].copy())
+ }
+
+ return copy;
+ }
+
+ public crossover = (partner: DNA) => {
+ if (this.size !== partner.size) {
+ console.log("DNA PARTNERS DO NOT MATCH IN SIZE!");
+ throw Error()
+ }
+ const newDNA = new DNA(0);
+ newDNA.size = this.size;
+ const mid = Math.floor(Math.random() * this.size);
+ for (let i = 0; i < this.size; i++) {
+ if (i < mid) {
+ newDNA.genes.push(this.genes[i].copy())
+ } else {
+ newDNA.genes.push(partner.genes[i].copy())
+ }
+ }
+
+ return newDNA;
+ }
+
+ public mutation = () => {
+ for (let i = 0; i < this.genes.length; i++) {
+ if (Math.random() < 0.01) {
+ this.genes[i] = Vec2.Random();
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/games/GeneticMissiles/utils/Explosion.ts b/src/games/GeneticMissiles/utils/Explosion.ts
new file mode 100644
index 0000000..8a2e90f
--- /dev/null
+++ b/src/games/GeneticMissiles/utils/Explosion.ts
@@ -0,0 +1,40 @@
+import { Vec2 } from "@website/games/utils/vec2";
+import { CanvasRenderer } from "@website/games/utils/canvas";
+
+export class Explosion {
+ public frame: number = 0;
+ public totalFrames: number = 10;
+ public started: boolean = false;
+ public complete: boolean = false;
+ public pos?: Vec2;
+ constructor(
+ public width: number,
+ public height: number,
+ ) {
+
+ }
+
+ public setPos = (v: Vec2) => {
+ this.pos = new Vec2(v.x - (this.width / 2), v.y - (this.height / 2));
+ }
+
+ public start = () => {
+ this.started = true;
+ }
+
+ public update = () => {
+ if (this.started && this.frame < this.totalFrames) {
+ this.frame++;
+ }
+
+ if (this.frame === this.totalFrames - 1) {
+ this.complete = true;
+ }
+ }
+
+ public render = (renderer: CanvasRenderer, sprites: HTMLImageElement[]) => {
+ if (this.started && this.pos && !this.complete) {
+ renderer.image(sprites[this.frame], this.pos.x, this.pos.y, { width: this.width, height: this.height });
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/games/GeneticMissiles/utils/Missile.ts b/src/games/GeneticMissiles/utils/Missile.ts
new file mode 100644
index 0000000..dc34467
--- /dev/null
+++ b/src/games/GeneticMissiles/utils/Missile.ts
@@ -0,0 +1,132 @@
+import { CanvasRenderer } from "@website/games/utils/canvas";
+import { Vec2 } from "@website/games/utils/vec2";
+import { DNA } from "./DNA";
+import { Polygon } from "@website/games/utils/polygon";
+import { Explosion } from "./Explosion";
+
+export class Missile {
+ public pos: Vec2 = new Vec2();
+ public vel: Vec2 = new Vec2();
+ public acc: Vec2 = new Vec2();
+ public sprite: number = 0;
+ public dna: DNA;
+ public fitness: number = 0;
+ // public bounds: { min: Vec2, max: Vec2 }
+ public obstacles: Polygon[] = []
+ public dead: boolean = false;
+ public completed: boolean = false;
+ public exploded: boolean = false;
+ public explosion: Explosion = new Explosion(50, 50);
+ public polygon: Polygon;
+
+ constructor(
+ public width: number,
+ public height: number,
+ dna: number | DNA,
+ public bounds: {
+ max: Vec2,
+ min: Vec2
+ },
+ public target: Vec2,
+ initialPos?: Vec2,
+ obstacles?: {pos: Vec2, size: Vec2}[],
+ public targetSize?: number
+ ) {
+ if (initialPos) this.pos = initialPos.copy();
+ this.dna = new DNA(dna);
+ // this.bounds = bounds;
+ if (obstacles) {
+ this.obstacles = obstacles.map(obs => {
+ const { pos, size } = obs;
+ return Polygon.fromRectangle(pos.x, pos.y, size.x, size.y)
+ })
+ // for (let i = 0; i < obstacles.length; i++) {
+ // const { pos, size } = obstacles[i];
+ // this.obstacles.push(Polygon.fromRectangle(pos.x, pos.y, size.x, size.y));
+ // }
+ }
+ this.polygon = Polygon.fromRectangle(this.pos.x, this.pos.y, width, height);
+ }
+
+ public calcFitness = (target: Vec2) => {
+ const d = Vec2.Distance(this.pos, target);
+ if (d === 0) {
+ this.fitness = 1 / 0.0001;
+ } else {
+ this.fitness = 1 / d;
+ }
+
+ }
+
+ public checkBounds = () => {
+ for (let i = 0; i < this.polygon.vertices.length; i++) {
+ const { x, y } = this.polygon.vertices[i];
+ if (x < this.bounds.min.x || x > this.bounds.max.x || y < this.bounds.min.y || y > this.bounds.max.y) {
+ this.dead = true;
+ }
+ }
+ }
+
+ public checkCollision = () => {
+ for (let i = 0; i < this.obstacles.length; i++) {
+ const obs = this.obstacles[i];
+ if (Polygon.doIntersect(this.polygon, obs)) {
+ this.dead = true;
+ }
+ }
+ }
+
+ public checkComplete = () => {
+ const point = new Vec2(this.pos.x + (this.width / 2), this.pos.y);
+ const d = Vec2.Distance(point, this.target);
+ if (d <= (this.targetSize ?? 25) + 10) {
+ this.completed = true;
+ this.explosion.setPos(point.copy());
+ this.explosion.start();
+ this.pos = this.target.copy();
+ }
+ }
+
+
+ public applyForce = (force: Vec2) => {
+ this.acc.mult(0);
+ this.acc.add(force);
+ }
+
+ public update = (geneCount: number) => {
+ this.checkBounds();
+ this.checkCollision();
+ this.checkComplete();
+ if (this.dead || this.completed) return;
+ const force = this.dna.genes[geneCount];
+ force.setMagnitude(0.1)
+ this.applyForce(force);
+
+ this.pos.add(this.vel);
+ this.vel.add(this.acc);
+
+ this.polygon = Polygon.fromRectangle(this.pos.x, this.pos.y, this.width, this.height);
+ this.polygon.rotate(this.vel.heading() + (Math.PI / 2));
+ }
+
+
+ public render = (renderer: CanvasRenderer, sprite: HTMLImageElement, deadSprite?: HTMLImageElement) => {
+ if (this.completed) return;
+ const { ctx } = renderer;
+
+ ctx.save();
+ ctx.translate(this.pos.x, this.pos.y);
+ ctx.rotate(this.vel.heading() + (Math.PI / 2));
+
+ if (this.dead) {
+ if (deadSprite) {
+ renderer.image(deadSprite, 0, 0, { width: this.width, height: this.height });
+ } else {
+ renderer.rect(0, 0, this.width, this.height, { fill: "#0000ff" });
+ }
+ } else {
+ renderer.image(sprite, 0, 0, { width: this.width, height: this.height });
+ }
+ ctx.restore();
+ }
+}
\ No newline at end of file
diff --git a/src/games/GeneticMissiles/utils/Population.ts b/src/games/GeneticMissiles/utils/Population.ts
new file mode 100644
index 0000000..62748ab
--- /dev/null
+++ b/src/games/GeneticMissiles/utils/Population.ts
@@ -0,0 +1,80 @@
+import { CanvasRenderer } from "@website/games/utils/canvas";
+import { DNA } from "./DNA";
+import { Missile } from "./Missile";
+import { Vec2 } from "@website/games/utils/vec2";
+import { randomArray } from "@website/games/utils/math";
+
+export class Population {
+ public missiles: Missile[] = [];
+ public matingPool: DNA[] = [];
+ constructor(
+ public size: number,
+ public missileParams: ConstructorParameters
+ ) {
+ for (let i = 0; i < this.size; i++) {
+ this.missiles.push(new Missile(...missileParams));
+ }
+ }
+
+ public evaluate = (target: Vec2) => {
+ this.matingPool = [];
+ let maxFit = 0;
+ for (let i = 0; i < this.missiles.length; i++) {
+ this.missiles[i].calcFitness(target);
+ maxFit = Math.max(this.missiles[i].fitness, maxFit);
+ }
+
+ // Normalization
+ for (let i = 0; i < this.missiles.length; i++) {
+ this.missiles[i].fitness /= maxFit;
+ }
+
+ for (let i = 0; i < this.missiles.length; i++) {
+ const n = this.missiles[i].fitness * 10;
+ for (let j = 0; j < n; j++) {
+ this.matingPool.push(this.missiles[i].dna.copy())
+ }
+ }
+ let successful = 0;
+ for (let i = 0; i < this.missiles.length; i++) {
+ if (this.missiles[i].completed) {
+ successful++;
+ }
+ }
+ return successful;
+ }
+
+ public selection = () => {
+ this.missiles = [];
+ for (let i = 0; i < this.size; i++) {
+ const parentA = randomArray(this.matingPool);
+ const parentB = randomArray(this.matingPool);
+ if (!parentA || !parentB) {
+ console.log("UNDEFINED PARENTS", { parentA, parentB });
+ }
+ const child = parentA.crossover(parentB);
+ child.mutation();
+ const newMissile = new Missile(this.missileParams[0], this.missileParams[1], child, this.missileParams[3], this.missileParams[4], this.missileParams[5], this.missileParams[6], this.missileParams[7]);
+ this.missiles.push(newMissile);
+ }
+
+ }
+
+ public run = (renderer: CanvasRenderer, sprite: HTMLImageElement, geneCount: number, deadSprite?: HTMLImageElement, explosionSprites?: HTMLImageElement[]) => {
+ for (let i = 0; i < this.missiles.length; i++) {
+ const missile = this.missiles[i];
+ missile.update(geneCount);
+ missile.render(renderer, sprite, deadSprite);
+ missile.explosion.update();
+ missile.explosion.render(renderer, explosionSprites as HTMLImageElement[]);
+ }
+ }
+
+ public allDead = () => {
+ for (let i = 0; i < this.missiles.length; i++) {
+ if (this.missiles[i].completed) continue;
+ if (!this.missiles[i].dead) return false;
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/games/IslamicStarPatterns/index.tsx b/src/games/IslamicStarPatterns/index.tsx
new file mode 100644
index 0000000..50b1e57
--- /dev/null
+++ b/src/games/IslamicStarPatterns/index.tsx
@@ -0,0 +1,220 @@
+import React from "react";
+import Canvas from "../Canvas";
+import { Vec2 } from "../utils/vec2";
+import { Polygon } from "../utils/polygon";
+import { CanvasRenderer } from "../utils/canvas";
+import { viewport } from "../utils/math";
+import { HankinPolygon } from "./utils/HankinPolygon";
+import { useStateRef } from "../utils/hooks";
+import {
+ Slider,
+ SliderTrack,
+ SliderFilledTrack,
+ SliderThumb,
+ SliderMark,
+ VStack,
+ Box,
+ Text,
+ RadioGroup,
+ Radio,
+ HStack,
+ useToken
+} from "@chakra-ui/react";
+import { radians } from "../utils/math";
+import MathJax from "react-mathjax";
+import ColorPicker from "@website/components/ColorPicker";
+
+const IslamicStarPattern: React.FC = () => {
+
+ type BaseShape = "square" | "hexagon" | "triangle";
+ const { vh } = viewport();
+ const height = vh * 0.8;
+ const width = height;
+
+ const SQ_SIZE = height / 3;
+ const [purple500, blue500, gray100] = useToken("colors", ["brand.purple.500", "brand.blue.500", "gray.100"])
+
+ const [angle, setAngle] = useStateRef(60);
+ const [baseShape, setBaseShape] = useStateRef("square");
+ const [useSquares, setUseSquares] = useStateRef(false);
+
+
+ const hex_size = new Vec2(Math.round(width / 6), Math.round(width / 6) * Math.sin(60 * (Math.PI / 180)));
+
+ const backgroundColors = ["#E1E2EF", "#BFACAA", "#D2D0BA", gray100];
+ const shapeColors = ["#05204A", "#6BAA75", "#5E747F", purple500, blue500];
+
+ const [shapeColor, setShapeColor] = useStateRef(shapeColors[0]);
+ const [background, setBackground] = useStateRef(backgroundColors[0]);
+
+ const generatePackedHexagons = (radius: number, rows: number, cols: number) => {
+ const size = new Vec2(radius, radius * Math.sin(radians(60)));
+ const offset = (size.x * Math.cos(radians(60))) / 2;
+ const polys: Polygon[] = [];
+
+ for (let i = 0; i < rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ const xPos = size.x * (j + 1) + (size.x * Math.cos(radians(60)) * j) - offset;
+ if (j % 2 !== 0) {
+ if (i === 0) {
+ for (let k = 0; k < 2; k++) {
+ const hex = Polygon.fromHexagon(xPos, size.y * (k * 2), size.x)
+ polys.push(hex);
+ }
+ } else {
+ const hex = Polygon.fromHexagon(xPos, size.y * (2 * i + 2), size.x);
+ polys.push(hex);
+ }
+ } else {
+ const hex = Polygon.fromHexagon(xPos, size.y * (2 * i + 1), size.x);
+ polys.push(hex);
+ }
+ }
+ }
+
+ return polys;
+ }
+
+ const generatePackedSquares = (size: number, rows: number, cols: number) => {
+ const polys: Polygon[] = []
+ for (let i = 0; i < rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ const poly = Polygon.fromRectangle(j * size, i * size, size, size);
+ polys.push(poly);
+ }
+ }
+ return polys;
+ }
+
+ const generatePackedTriangles = (radius: number, rows: number, cols: number) => {
+ const polys: Polygon[] = [];
+ const size = new Vec2(2 * radius * Math.sin(radians(60)), (3 * radius) / 2);
+ const offset = (size.x / 2);
+ for (let i = 0; i < rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ const one = Polygon.fromTriangle((size.x / 2) * (2 * j + 1) - (i % 2 === 0 ? offset : offset * 2), radius + (size.y * i), radius, { invert: true });
+ const two = Polygon.fromTriangle((size.x) * (j + 1) - (i % 2 === 0 ? offset : offset * 2), (radius / 2) + (size.y * i), radius);
+ polys.push(one);
+ polys.push(two);
+ }
+ }
+
+ return polys;
+ }
+
+ const generatePackedPentagons = (radius: number, rows: number, cols: number) => {
+ const polys: Polygon[] = [];
+
+ for (let i = 0; i < rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ const poly = Polygon.fromNSided(5, radius * Math.sin(radians(72)) * (2 * j + 1), radius, radius, { invert: true });
+ // poly.rotate(radians(72 * j));
+ polys.push(poly);
+ }
+ }
+
+ return polys;
+ }
+
+ const createHankinPolygons = (poly: Polygon, angle: number) => {
+ const hp = HankinPolygon.fromPolygon(poly);
+ // hp.addDelta(Math.round(width / 6) / 2);
+ hp.rotateHankins(angle, { degrees: true });
+ hp.findHankinIntersections();
+ return hp;
+ // const newPoly = hp.createPolygonFromHankins();
+ // return newPoly as Polygon;
+ }
+
+ const handleDraw = (ctx: CanvasRenderingContext2D, renderer: CanvasRenderer, frame: number) => {
+
+ renderer.clear();
+ renderer.rect(0, 0, ctx.canvas.width, ctx.canvas.height, { fill: background.ref.current });
+ let polys: HankinPolygon[] = [];
+ if (baseShape.ref.current === "hexagon") {
+ polys = generatePackedHexagons(Math.round(width / 6), 4, 4).map( poly => createHankinPolygons(poly, -angle.ref.current));
+ }
+
+ if (baseShape.ref.current === "square") {
+ polys = generatePackedSquares(width / 3, 3, 3).map( poly => createHankinPolygons(poly, angle.ref.current));
+ }
+
+
+
+ if (baseShape.ref.current === "triangle") {
+ const radius = Math.round(width / (2 * 3 * Math.sin(radians(60))));
+ polys = generatePackedTriangles(radius, 4, 4).map( poly => createHankinPolygons(poly, -angle.ref.current));
+ }
+ // generatePackedTriangles(radius, 4, 4).forEach( poly => {
+ // renderer.polygon(poly, { fill: shapeColor.ref.current });
+ // })
+
+ generatePackedPentagons(100, 1, 2).forEach( poly => renderer.polygon(poly, { stroke: shapeColor.ref.current }));
+
+ // for (let i = 0; i < polys.length; i++) {
+ // const fullPoly = polys[i].createPolygonFromHankins();
+ // if (fullPoly) {
+ // renderer.polygon(fullPoly, { fill: shapeColor.ref.current });
+ // }
+ // }
+ }
+
+ return (
+
+
+
+
+
+ Choose Angle ():
+
+ setAngle(val)} min={1} max={89} w={width + "px"} >
+
+ {angle.state}
+
+
+
+
+
+
+
+
+
+ Choose Base Polygon:
+ setBaseShape(val as BaseShape)} value={baseShape.state}>
+
+ Square
+ Hexagon
+ Triangle
+
+
+
+
+
+
+ Background Color:
+ setBackground(color)} />
+
+
+ Shape Color:
+ setShapeColor(color)}/>
+
+
+
+
+ )
+}
+
+
+export default IslamicStarPattern;
\ No newline at end of file
diff --git a/src/games/IslamicStarPatterns/utils/Hankin.ts b/src/games/IslamicStarPatterns/utils/Hankin.ts
new file mode 100644
index 0000000..dd96c54
--- /dev/null
+++ b/src/games/IslamicStarPatterns/utils/Hankin.ts
@@ -0,0 +1,64 @@
+import { Vec2 } from "@website/games/utils/vec2";
+import { Edge } from "@website/games/utils/edge";
+import { CanvasRenderer } from "@website/games/utils/canvas";
+
+export class Hankin {
+ public end: Vec2;
+ public intersection?: Vec2;
+ public prevDist: number = 0;
+ constructor(
+ public start: Vec2, public v: Vec2) {
+ this.end = Vec2.Add(start, v);
+ }
+
+ rotate = (angle: number, opts?: { degrees?: boolean }) => {
+ this.v.rotate(angle, opts);
+ this.end = Vec2.Add(this.start, this.v);
+ }
+
+ findIntersection = (other: Hankin) => {
+ // Reference: http://paulbourke.net/geometry/pointlineplane/
+ // this.start, this.v, this.end = P1, (P2 - P1), P2
+ // other.start, other.v, other.end = P3, (P4 - P3), P4
+
+ const deno = (other.v.y * this.v.x) - (other.v.x * this.v.y)
+ const numA = (other.v.x * (this.start.y - other.start.y)) - (other.v.y * (this.start.x - other.start.x));
+ const numB = (this.v.x * (this.start.y - other.start.y)) - (this.v.y * (this.start.x - other.start.x));
+
+ const ua = numA / deno;
+ const ub = numB / deno;
+
+ const x = this.start.x + (ua * this.v.x);
+ const y = this.start.y + (ua * this.v.y);
+
+
+
+ if (ua > 0 && ub > 0) {
+ const candidate = new Vec2(x, y);
+ const d1 = Vec2.Distance(candidate, this.start);
+ const d2 = Vec2.Distance(candidate, other.start);
+ const total = d1 + d2;
+ if (!this.intersection) {
+ this.intersection = candidate.copy();
+ this.prevDist = total;
+ } else if (total < this.prevDist) {
+ this.prevDist = total;
+ this.intersection = candidate.copy();
+ }
+ }
+ }
+
+ render = (renderer: CanvasRenderer, color: string, width: number) => {
+ // renderer.circle(this.start.x, this.start.y, 2, 0, Math.PI * 2, { stroke: "#ffff00" });
+ if (this.intersection) {
+ renderer.line({ from: this.start, to: this.intersection, color, width });
+ } else {
+ renderer.line({ from: this.start, to: this.end, color, width });
+ }
+
+ // if (this.intersection) {
+ // renderer.circle(this.intersection.x, this.intersection.y, 5, 0, Math.PI * 2, { fill: "#ff0000" });
+ // }
+ }
+}
+
diff --git a/src/games/IslamicStarPatterns/utils/HankinPolygon.ts b/src/games/IslamicStarPatterns/utils/HankinPolygon.ts
new file mode 100644
index 0000000..a8e6b24
--- /dev/null
+++ b/src/games/IslamicStarPatterns/utils/HankinPolygon.ts
@@ -0,0 +1,113 @@
+import { Vec2 } from "@website/games/utils/vec2";
+import { Polygon } from "@website/games/utils/polygon";
+import { Hankin } from "./Hankin";
+import { CanvasRenderer } from "@website/games/utils/canvas";
+
+export class HankinPolygon extends Polygon {
+ public hankins: Hankin[][] = []
+ constructor(
+ vertices?: Vec2[]
+ ) {
+ super(vertices);
+
+ if (this.hasEdges) {
+ for (let i = 0; i < this.edges.length; i++) {
+ const edge = this.edges[i];
+ const mid = Vec2.Add(edge.a.copy(), edge.b.copy()).mult(0.5);
+ const v1 = Vec2.Subtract(mid, edge.a);
+ const v2 = Vec2.Subtract(mid, edge.b);
+ v1.normalize();
+ v2.normalize();
+ const h1 = new Hankin(mid, v1);
+ const h2 = new Hankin(mid, v2);
+ this.hankins.push([h1, h2])
+ }
+ }
+ }
+
+ get hasHankins() {
+ return this.hankins.length > 0;
+ }
+
+ addDelta = (delta: number) => {
+ if (!this.hasEdges || !this.hasHankins || delta <= 0) {
+ return;
+ }
+ for (let i = 0; i < this.edges.length; i++) {
+ const edge = this.edges[i];
+ const mid = Vec2.Add(edge.a.copy(), edge.b.copy()).mult(0.5);
+ const v1 = Vec2.Subtract(mid, edge.a);
+ const v2 = Vec2.Subtract(mid, edge.b);
+ v1.setMagnitude(delta);
+ v2.setMagnitude(delta);
+ const offset1 = Vec2.Add(mid, v2);
+ const offset2 = Vec2.Add(mid, v1);
+ v1.normalize();
+ v2.normalize();
+ const h1 = new Hankin(offset1, v1);
+ const h2 = new Hankin(offset2, v2);
+
+ this.hankins[i] = [h1, h2];
+ }
+ }
+
+ rotateHankins = (angle: number, opts?: { degrees: boolean }) => {
+ if (!this.hasHankins) {
+ console.log("No hankins!!");
+ return;
+ }
+ for (let i = 0; i < this.hankins.length; i++) {
+ const [h1, h2] = this.hankins[i];
+ h1.rotate(-angle, opts);
+ h2.rotate(angle, opts);
+ }
+ }
+
+ renderHankins = (renderer: CanvasRenderer, color: string, width: number) => {
+ for (let i = 0; i < this.hankins.length; i++) {
+ for (let j = 0; j < this.hankins[i].length; j++) {
+ this.hankins[i][j].render(renderer, color, width);
+ }
+ }
+ }
+
+ findHankinIntersections = () => {
+ if (!this.hasHankins) {
+ console.log("no hankins!!");
+ return;
+ }
+ for (let i = 0; i < this.hankins.length; i++) {
+ const [h1, h2] = this.hankins[i];
+ for (let j = 0; j < this.hankins.length; j++) {
+ if (i !== j) {
+ const edge = this.hankins[j];
+ h1.findIntersection(edge[0]);
+ h1.findIntersection(edge[1]);
+ h2.findIntersection(edge[0]);
+ h2.findIntersection(edge[1]);
+ }
+ }
+ }
+ }
+
+ createPolygonFromHankins = () => {
+ if (!this.hasHankins) {
+ console.log("no hankins!!");
+ return;
+ }
+ const vertices: Vec2[] = [];
+ for (let i = 0; i < this.hankins.length; i++) {
+ const [h1, h2] = this.hankins[i];
+ vertices.push(h1.start.copy());
+ if (h2.intersection) {
+ vertices.push(h2.intersection.copy());
+ }
+ }
+
+ return new Polygon(vertices);
+ }
+
+ static fromPolygon = (poly: Polygon) => {
+ return new HankinPolygon(poly.vertices);
+ }
+}
diff --git a/src/games/Snake/Game.tsx b/src/games/Snake/Game.tsx
new file mode 100644
index 0000000..6a2b6f0
--- /dev/null
+++ b/src/games/Snake/Game.tsx
@@ -0,0 +1,266 @@
+import React, { useState, useEffect } from "react";
+import { VStack, Box, HStack, Text } from "@chakra-ui/react";
+import { Cell } from "./utils/Cell";
+import { Vec2 } from "../utils/vec2";
+import GridCell from "./GridCell";
+
+export type Reason = "self" | "bounds" | undefined;
+type GameProps = {
+ rows: number,
+ cols: number,
+ cellSize: number,
+ onGameOver?: (reason?: Reason) => void,
+ onScore?: () => void
+}
+
+
+const Game: React.FC = ({ rows, cols, cellSize, onGameOver = () => {}, onScore = () => {} }) => {
+
+ const [grid, setGrid] = useState([]);
+
+ const createKey = (prefix: string, id: string | number) => {
+ return prefix + id;
+ }
+
+ // Creates an empty grid
+ const initGrid = (row: number, cols: number, cellSize: number): Cell[][] => {
+ const result: Cell[][] = []
+ for (let i = 0; i < row; i++){
+ let temp: Cell[] = []
+ for (let j = 0; j < cols; j++){
+ temp.push(
+ new Cell(
+ new Vec2(j, i),
+ "empty",
+ cellSize
+ )
+ )
+ }
+ result.push(temp);
+ }
+
+ return result;
+ }
+
+ useEffect(() => {
+ // Empty grid for initial render
+ setGrid(initGrid(rows, cols, cellSize));
+
+ // Variables
+ let snake: Vec2[] = [];
+ const snakeLen = 5;
+ let dir: "r" | "l" | "u" | "d" = "r"
+ let gameStarted = false;
+ let gameOver = false;
+ let callCount = 0;
+ const bounds = {
+ min: new Vec2(),
+ max: new Vec2(cols, rows)
+ }
+ let food: Vec2;
+ let deathReason: Reason;
+
+ // Keypress handling
+ window.addEventListener("keydown", e => {
+ if (["Space", "ArrowUp", "ArrowDown", "ArrowRight", "ArrowLeft", "KeyW", "KeyA", "KeyD", "KeyS"].includes(e.code)) {
+ e.preventDefault();
+ }
+
+ if (e.code === "Space") {
+ gameStarted = true;
+ gameOver = false;
+ }
+
+ if ((e.code === "ArrowRight" || e.code === "KeyD") && dir !== "l") {
+ dir = "r";
+ }
+
+ if ((e.code === "ArrowLeft" || e.code === "KeyA") && dir !== "r") {
+ dir = "l";
+ }
+
+ if ((e.code === "ArrowUp" || e.code === "KeyW") && dir !== "d") {
+ dir = "u"
+ }
+
+ if ((e.code === "ArrowDown" || e.code === "KeyS") && dir !== "u") {
+ dir = "d"
+ }
+
+ })
+
+ // Initializes the snake and renders itself and food
+ const initSnake = () => {
+ snake = [];
+ for (let i = snakeLen; i > 0; i--){
+ const v = new Vec2((Math.floor(cols / 2) + i) - Math.round(snakeLen / 2), Math.floor(rows / 2))
+ snake.push(v);
+ }
+ setGrid(prev => {
+ const grid = initGrid(rows, cols, cellSize);
+ grid[food.y][food.x].type = "food";
+
+ snake.forEach(s => {
+ grid[s.y][s.x].type = "snake";
+ })
+ return grid;
+ })
+ }
+
+ // Handles snakes movement and rendering
+ const moveSnake = () => {
+
+ // Tail initial position
+ let x = snake[0].x;
+ let y = snake[0].y;
+
+ // Updates position based on direction of travel
+ if (dir === "r") {
+ x += 1;
+ }
+
+ if (dir === "l") {
+ x -= 1;
+ }
+
+ if (dir === "u") {
+ y -= 1;
+ }
+
+ if (dir === "d") {
+ y += 1;
+ }
+
+ // Remove the tail
+ let tail = snake.pop() ?? new Vec2();
+ // Update snake tail position
+ snake.unshift(new Vec2(x, y));
+
+ // Rendering
+ setGrid(prev => {
+ const copy = [...prev];
+
+ // Change old tail position back to empty
+ if (tail.isInbounds(bounds, { maxInclusive: true })){
+ copy[tail.y][tail.x].type = "empty";
+ }
+
+ // Render tail
+ snake.forEach(s => {
+ if (s.isInbounds(bounds, { maxInclusive: true })) {
+ copy[s.y][s.x].type = "snake";
+ }
+ })
+
+ return copy;
+ })
+ }
+
+ //
+ const createFood = () => {
+ food = Vec2.RandomInteger(bounds);
+ // console.log("new food");
+ for (let i = 0; i < snake.length; i++) {
+ if (snake[i].equals(food)) {
+ createFood()
+ return;
+ }
+ }
+
+ // console.log({ fx: food.x, fy: food.y });
+ setGrid(prev => {
+ const copy = [...prev];
+ for (let i = 0; i < prev.length; i++) {
+ for (let j = 0; j < prev[i].length; j++) {
+ if (prev[i][j].type === "food") copy[i][j].type = "empty";
+ }
+ }
+ copy[food.y][food.x].type = "food";
+
+ return copy;
+ })
+ }
+
+ const handleReset = (reason?: Reason) => {
+ gameOver = true;
+ gameStarted = false;
+ dir = "r";
+ callCount = 0;
+ deathReason = reason;
+ }
+
+ createFood();
+ const IID = setInterval(() => {
+ if (!gameStarted) {
+ initSnake();
+ }
+
+ if (!gameOver && gameStarted) {
+ moveSnake();
+ }
+
+ const head = snake[0];
+ for (let i = 1; i < snake.length; i++){
+ const s = snake[i];
+ if (s.equals(head)){
+ gameOver = true;
+ handleReset("self");
+ }
+ }
+
+ if (!head.isInbounds(bounds, { maxInclusive: true })) {
+ gameOver = true;
+ handleReset("bounds");
+ }
+
+ if (food.equals(head)) {
+ snake.push(head.copy());
+ createFood();
+ onScore();
+ }
+
+
+ if (gameOver && callCount < 1) {
+ onGameOver(deathReason)
+ callCount++
+ }
+
+ }, 100)
+
+ return () => {
+ clearInterval(IID);
+ }
+ }, [rows, cols, cellSize])
+
+ if (grid.length > 0) {
+ return (
+
+ {
+ grid.map( (row, rIdx) => {
+
+ return (
+
+ {
+ row.map( (cell, cIdx) => {
+ return (
+
+ )
+ })
+ }
+
+ )
+ })
+ }
+
+ )
+ } else {
+ return (
+
+ Loading...
+
+ )
+ }
+
+}
+
+export default Game;
\ No newline at end of file
diff --git a/src/games/Snake/GridCell.tsx b/src/games/Snake/GridCell.tsx
new file mode 100644
index 0000000..618602e
--- /dev/null
+++ b/src/games/Snake/GridCell.tsx
@@ -0,0 +1,47 @@
+import React, { useEffect, useState } from "react";
+import { Box, BoxProps, useColorModeValue } from "@chakra-ui/react";
+import { Cell } from "./utils/Cell";
+
+type GridCellProps = {
+ cell: Cell
+}
+
+const GridCell: React.FC = ({ cell }) => {
+
+ const [styleProps, setStyleProps] = useState({});
+ const emptyBg = useColorModeValue("white", "gray.800");
+ const snakeBg = "brand.purple.500";
+ const foodBg = "red.500";
+
+ useEffect(() => {
+ if (cell.type === "empty") {
+ setStyleProps({
+ bg: emptyBg,
+ })
+ } else if (cell.type === "snake") {
+ // console.log("snake type");
+ setStyleProps({
+ bg: snakeBg
+ })
+ } else {
+ setStyleProps({
+ bg: foodBg
+ })
+ }
+ }, [cell.type, setStyleProps, emptyBg])
+
+ return (
+
+ )
+}
+
+export default GridCell;
\ No newline at end of file
diff --git a/src/games/Snake/index.tsx b/src/games/Snake/index.tsx
new file mode 100644
index 0000000..a65477c
--- /dev/null
+++ b/src/games/Snake/index.tsx
@@ -0,0 +1,128 @@
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import {
+ VStack,
+ HStack,
+ Text,
+ Modal,
+ ModalOverlay,
+ ModalContent,
+ ModalHeader,
+ ModalFooter,
+ ModalBody,
+ Button,
+ useDisclosure,
+ Kbd
+} from "@chakra-ui/react"
+import Canvas from "../Canvas";
+import { line, clearCanvas, rect } from "../utils/canvas";
+import { Vec2 } from "../utils/vec2";
+import Game from "./Game";
+import type { Reason } from "./Game";
+
+const Snake: React.FC = () => {
+ const CELL_SIZE = 20;
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
+
+ const calcDimensions = (height: number, width: number, cellSize: number) => {
+ let rows: number = 0;
+ if (height % cellSize === 0) {
+ rows = height / cellSize;
+ } else {
+ rows = (height - (height % cellSize)) / cellSize;
+ }
+ let cols: number = 0;
+ if (width % cellSize === 0) {
+ cols = width / cellSize;
+ } else {
+ cols = (width - (width % cellSize)) / cellSize;
+ }
+
+ return {
+ rows,
+ cols
+ }
+ }
+
+ const { rows, cols } = calcDimensions(vh * 0.6, vh * 0.6, CELL_SIZE);
+
+ const { isOpen, onClose, onOpen } = useDisclosure()
+ const initialRef = useRef(null);
+
+ const [score, setScore] = useState(0);
+ const [gameOverMessage, setGameOverMessage] = useState("Game over!");
+ const [highscore, setHighscore] = useState(0);
+ const [newHS, setNewHS] = useState(false);
+
+ const handleScore = () => {
+ setScore(prev => prev + 10);
+ }
+
+ const handleTryAgain = () => {
+ onClose();
+ setScore(0);
+ setNewHS(false);
+ }
+
+ const handleGameOver = (reason?: Reason) => {
+ onOpen();
+
+ if (reason) {
+ if (reason === "self") {
+ setGameOverMessage("You ran into yourself!")
+ } else {
+ setGameOverMessage("You went out of bounds!")
+ }
+ }
+ }
+
+ useEffect(() => {
+ const local = localStorage.getItem("snake-highscore");
+ if (local) {
+ setHighscore(parseInt(local));
+ } else {
+ setHighscore(0);
+ }
+ }, [])
+
+ useEffect(() => {
+ if (score > highscore) {
+ setHighscore(score);
+ localStorage.setItem("snake-highscore", score.toString());
+ setNewHS(true);
+ }
+ }, [score, highscore])
+
+ return (
+ <>
+
+
+ Score: {score}
+ High Score: {highscore}
+
+
+
+ Controls
+ Press space to start
+ Use arrow keys or WASD to control the snake.
+
+
+
+
+ Game Over :{"("}
+
+ {gameOverMessage}
+ Score: {score}
+ {newHS && New high score!}
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default Snake;
\ No newline at end of file
diff --git a/src/games/Snake/utils/Cell.tsx b/src/games/Snake/utils/Cell.tsx
new file mode 100644
index 0000000..d8a0b95
--- /dev/null
+++ b/src/games/Snake/utils/Cell.tsx
@@ -0,0 +1,9 @@
+import { Vec2 } from "@website/games/utils/vec2";
+
+export class Cell {
+ constructor(
+ public position: Vec2,
+ public type: "snake" | "empty" | "food",
+ public cellSize: number
+ ){}
+}
\ No newline at end of file
diff --git a/src/games/index.ts b/src/games/index.ts
new file mode 100644
index 0000000..ff830b4
--- /dev/null
+++ b/src/games/index.ts
@@ -0,0 +1,58 @@
+import React from "react";
+import Snake from "./Snake";
+import CirclePacking from "./CirclePacking";
+import GeneticMissiles from "./GeneticMissiles";
+import Flocking from "./Flocking";
+import Asteroids from "./Asteroids";
+import IslamicStarPattern from "./IslamicStarPatterns";
+
+export type Games = "snake" | "circle-packing" | "genetic-missiles" | "flocking" | "asteroids" | "islamic-star-pattern";
+export type GameMetadata = {
+ component: React.ElementType,
+ title: string
+ description: string,
+ tags?: string[]
+}
+
+export const games: Record = {
+ snake: {
+ component: Snake,
+ title: "Snake",
+ description: "The infamous Snake game made popular on Nokia phones in the 2000s.",
+ tags: ["Game", "Retro"]
+ },
+ "circle-packing": {
+ component: CirclePacking,
+ title: "Circle Packing",
+ description: "Packs randomly placed and sized circles to generate a provided image",
+ tags: ["Simulation", "Art"]
+ },
+ "genetic-missiles": {
+ component: GeneticMissiles,
+ title: "Genetic Missiles",
+ description: "Genetic algorithm simulation (meta-heuristics). Survival of the fittest in action.",
+ tags: ["Simulation"]
+ },
+ "flocking": {
+ component: Flocking,
+ title: "Flocking",
+ description: "Simulation of flocking behavior found in schools of fish.",
+ tags: ["Simulation"]
+ },
+ "asteroids": {
+ component: Asteroids,
+ title: "Asteroids",
+ description: "Retro asteroids game. Evade asteroids and shoot them to break them up.",
+ tags: ["Game", "Retro"]
+ },
+ "islamic-star-pattern": {
+ component: IslamicStarPattern,
+ title: "Islamic Star Pattern",
+ description: "Algorithmically generating polygon tiling patterns made popular by Islamic algebraic art.",
+ tags: ["Simulation", "Art"]
+ }
+}
+
+export const allTags = Object.values(games).flatMap( game => {
+ return game.tags;
+}).filter( s => s) as string[]
\ No newline at end of file
diff --git a/src/games/utils/canvas.ts b/src/games/utils/canvas.ts
new file mode 100644
index 0000000..021497c
--- /dev/null
+++ b/src/games/utils/canvas.ts
@@ -0,0 +1,196 @@
+import { Vec2 } from "./vec2";
+import { Polygon } from "./polygon";
+
+export type FillStrokeOpts = {
+ fill?: string,
+ stroke?: string
+}
+
+export type LineOpts = {
+ from: Vec2,
+ to: Vec2,
+ color?: string,
+ width?: number
+}
+
+export type ImageOpts = {
+ maintainAspect?: boolean,
+ scalingFactor?: number,
+ scaleX?: number,
+ scaleY?: number,
+ width?: number,
+ height?: number,
+ rotation?: number
+}
+
+export class CanvasRenderer{
+ constructor(
+ public ctx: CanvasRenderingContext2D
+ ){}
+
+ public clear = () => {
+ this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
+ }
+
+ public rect = (
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ { fill, stroke } : FillStrokeOpts
+ ) => {
+ if (fill) this.ctx.fillStyle = fill;
+ if (stroke) this.ctx.strokeStyle = stroke;
+ if (fill && stroke) {
+ this.ctx.fillRect(x, y, width, height);
+ this.ctx.strokeRect(x, y, width, height);
+ } else if (fill) {
+ this.ctx.fillRect(x, y, width, height);
+ } else if (stroke) {
+ this.ctx.strokeRect(x, y, width, height);
+ } else {
+ this.ctx.fillStyle = "#000000";
+ this.ctx.fillRect(x, y, width, height);
+ }
+ }
+
+ public line = ({ from, to, color = "#000000", width=1}: LineOpts) => {
+ this.ctx.strokeStyle = color;
+ this.ctx.lineWidth = width;
+ this.ctx.beginPath();
+ this.ctx.moveTo(from.x, from.y);
+ this.ctx.lineTo(to.x, to.y);
+ this.ctx.closePath();
+ this.ctx.stroke();
+ }
+
+ public circle = (
+ x: number,
+ y: number,
+ radius: number,
+ startAngle: number = 0,
+ endAngle: number = 2 * Math.PI,
+ { fill, stroke }: FillStrokeOpts
+ ) => {
+ this.ctx.beginPath();
+ this.ctx.arc(x, y, radius, startAngle, endAngle);
+ if (fill) {
+ this.ctx.fillStyle = fill
+ this.ctx.fill()
+ }
+
+ if (stroke) {
+ this.ctx.strokeStyle = stroke
+ this.ctx.stroke();
+ }
+ this.ctx.closePath();
+ }
+
+ public image = (
+ image: CanvasImageSource,
+ x: number,
+ y: number,
+ opts?: ImageOpts
+ ) => {
+ const { height, width } = image;
+ if (opts?.maintainAspect && opts?.scalingFactor) {
+ this.ctx.drawImage(image, x, y, (width as number) * opts.scalingFactor, (height as number) * opts.scalingFactor);
+ return;
+ }
+ let scaledX = width as number;
+ let scaledY = height as number;
+ if (opts?.scaleX) {
+ scaledX *= opts.scaleX
+ }
+
+ if (opts?.scaleY) {
+ scaledY *= opts.scaleY;
+ }
+
+ if (opts?.width && !opts.height && opts.maintainAspect) {
+ scaledX = opts.width;
+ scaledY = (opts.width / (width as number)) * (height as number)
+ }
+
+ if (opts?.height && !opts.width && opts.maintainAspect) {
+ scaledY = opts.height;
+ scaledX = (opts.height / (height as number)) * (width as number);
+ }
+
+ if (opts?.height && opts?.width) {
+ scaledX = opts.width;
+ scaledY = opts.height;
+ }
+
+ if (opts?.rotation) {
+ this.ctx.setTransform(1, 0, 0, 1, x, y);
+ this.ctx.rotate(opts.rotation);
+ const cx = x + (scaledX / 2);
+ const cy = y + (scaledY / 2);
+ this.ctx.drawImage(image, -scaledX / 2, -scaledY / 2, scaledX, scaledY);
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ return;
+ }
+
+ this.ctx.drawImage(image, x, y, scaledX, scaledY);
+ }
+
+ public polygon = (poly: Polygon, opts: FillStrokeOpts) => {
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(poly.vertices[0].x, poly.vertices[0].y);
+ for (let i = 1; i < poly.vertices.length; i++) {
+ const pt = poly.vertices[i];
+ this.ctx.lineTo(pt.x, pt.y);
+ }
+ this.ctx.closePath();
+ if (opts.fill) {
+ this.ctx.fillStyle = opts.fill;
+ this.ctx.fill();
+ }
+ if (opts.stroke) {
+ this.ctx.strokeStyle = opts.stroke;
+ this.ctx.stroke();
+ }
+
+ }
+
+ public polygonPoints = (poly: Polygon, opts: FillStrokeOpts) => {
+ for (let i = 0; i < poly.vertices.length; i++) {
+ const pt = poly.vertices[i];
+ this.circle(pt.x, pt.y, 1, 0, Math.PI * 2, opts);
+ }
+ }
+
+
+}
+
+
+
+export const clearCanvas = (ctx: CanvasRenderingContext2D) => {
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+}
+
+export const rect = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, { fill, stroke }: FillStrokeOpts ) => {
+ if (fill) ctx.fillStyle = fill;
+ if (stroke) ctx.strokeStyle = stroke;
+ if (fill && stroke) {
+ ctx.fillRect(x, y, width, height);
+ ctx.strokeRect(x, y, width, height);
+ } else if (fill) {
+ ctx.fillRect(x, y, width, height);
+ } else if (stroke) {
+ ctx.strokeRect(x, y, width, height);
+ } else {
+ ctx.fillStyle = "#000000";
+ ctx.fillRect(x, y, width, height);
+ }
+}
+
+export const line = (ctx: CanvasRenderingContext2D, { from, to, color = "#000000", width=1}: LineOpts) => {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.moveTo(from.x, from.y);
+ ctx.lineTo(to.x, to.y);
+ ctx.stroke();
+}
\ No newline at end of file
diff --git a/src/games/utils/edge.ts b/src/games/utils/edge.ts
new file mode 100644
index 0000000..8acd631
--- /dev/null
+++ b/src/games/utils/edge.ts
@@ -0,0 +1,17 @@
+import { Vec2 } from "./vec2";
+import { CanvasRenderer } from "./canvas";
+
+export class Edge{
+ constructor(
+ public a: Vec2,
+ public b: Vec2
+ ) {}
+
+ get midpoint() {
+ return Vec2.Add(this.a, this.b).mult(0.5)
+ }
+
+ render = (renderer: CanvasRenderer, color?: string, width?: number) => {
+ renderer.line({ from: this.a, to: this.b, color, width });
+ }
+}
\ No newline at end of file
diff --git a/src/games/utils/hooks.ts b/src/games/utils/hooks.ts
new file mode 100644
index 0000000..a2823af
--- /dev/null
+++ b/src/games/utils/hooks.ts
@@ -0,0 +1,57 @@
+import React, { useState, useRef } from "react";
+
+
+type SetStateRef = {
+ (cb: (prev: T) => T): void,
+ (value: T): void
+}
+
+type StateRef = {
+ ref: React.MutableRefObject,
+ state: T
+}
+
+export function useStateRef(initial: T): [StateRef, SetStateRef]{
+ const [state, setState] = useState(initial);
+ const ref = useRef(initial);
+
+ function setStateRef(cb: (prev: T) => T): void
+ function setStateRef(value: T): void;
+
+ function setStateRef(param: T | ((prev: T) => T)) : void {
+ setState(param);
+ if (param instanceof Function) {
+ ref.current = param(ref.current);
+ } else {
+ ref.current = param;
+ }
+ }
+
+ return [{ ref, state }, setStateRef]
+}
+
+export function useImage(paths: string[]): React.MutableRefObject
+export function useImage(paths: string): React.MutableRefObject
+
+export function useImage(paths: string | string[]): React.MutableRefObject | React.MutableRefObject {
+ const arrayRef = useRef([]);
+ const ref = useRef();
+ if (Array.isArray(paths)) {
+ for (let i = 0; i < paths.length; i++) {
+ const image = document.createElement("img");
+ image.onload = () => {
+ arrayRef.current.push(image);
+ }
+ image.src = paths[i];
+ }
+ } else {
+ const image = document.createElement("img");
+ image.onload = () => {
+ ref.current = image;
+ }
+ image.src = paths;
+ }
+
+ if (Array.isArray(paths)) return arrayRef;
+ return ref
+}
\ No newline at end of file
diff --git a/src/games/utils/math.ts b/src/games/utils/math.ts
new file mode 100644
index 0000000..ca59728
--- /dev/null
+++ b/src/games/utils/math.ts
@@ -0,0 +1,25 @@
+
+export const randInt = (min: number, max: number) => {
+ return Math.floor(Math.random() * (max - min + 1) + min);
+}
+
+export const viewport = () => {
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
+ const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
+
+ return { vh, vw };
+}
+
+export const random = (min: number, max: number) => {
+ return (Math.random() * (max - min) - min);
+}
+
+export function randomArray(arr: T[]): T{
+ return arr[Math.floor(Math.random() * arr.length)]
+}
+
+export const mapNumbers = (n: number, start1: number, stop1: number, start2: number, stop2: number) => {
+ return ((n-start1)/(stop1-start1))*(stop2-start2)+start2
+}
+
+export const radians = (deg: number) => deg * (Math.PI / 180);
\ No newline at end of file
diff --git a/src/games/utils/polygon.ts b/src/games/utils/polygon.ts
new file mode 100644
index 0000000..33f3766
--- /dev/null
+++ b/src/games/utils/polygon.ts
@@ -0,0 +1,175 @@
+import { Vec2 } from "./vec2";
+import { Edge } from "./edge";
+import { CanvasRenderer, FillStrokeOpts } from "./canvas";
+
+type RenderOpts = {
+ renderEdges?: boolean,
+ edgeColor?: string,
+ edgeWidth?: number
+}
+
+export class Polygon {
+ public vertices: Vec2[];
+ public edges: Edge[] = [];
+ // public hasEdges: boolean = false;
+
+ get hasEdges() {
+ return this.edges.length > 0
+ }
+
+ constructor(
+ vertices: Vec2[] = []
+ ) {
+ this.vertices = vertices;
+ this.createEdges();
+ }
+
+ public createEdges = () => {
+ if (this.vertices.length < 2) {
+ this.edges = []
+ return;
+ }
+
+ this.edges = []
+ for (let i = 0; i < this.vertices.length; i++) {
+ const a = this.vertices[i].copy();
+ const b = ((i + 1 < this.vertices.length) ? this.vertices[i + 1] : this.vertices[0]).copy();
+ this.edges.push(new Edge(a, b))
+ }
+ }
+
+ public render = (renderer: CanvasRenderer, fillStrokeOpts: FillStrokeOpts, opts?: RenderOpts) => {
+ if (opts?.renderEdges && this.hasEdges) {
+ for (let i = 0; i < this.edges.length; i++) {
+ this.edges[i].render(renderer, opts.edgeColor, opts.edgeWidth)
+ }
+ } else {
+ renderer.polygon(this, fillStrokeOpts)
+ }
+ }
+
+ public rotate = (angle: number) => {
+ const rPt = this.vertices[0];
+ for (let i = 0; i < this.vertices.length; i++) {
+ this.vertices[i] = Vec2.RotateAbout(rPt, this.vertices[i], angle);
+ }
+ }
+
+ static doIntersect = (a: Polygon, b: Polygon) => {
+ const polygons = [a, b];
+ let minA, maxA, projected, minB, maxB;
+ for (let i = 0; i < polygons.length; i++) {
+ const polygon = polygons[i];
+ for (let i1 = 0; i1 < polygon.vertices.length; i1++) {
+ const i2 = (i1 + 1) % polygon.vertices.length;
+ const p1 = polygon.vertices[i1];
+ const p2 = polygon.vertices[i2];
+
+ const normal = new Vec2(p2.y - p1.y, p1.x - p2.x);
+ minA = maxA = undefined;
+ for (let j = 0; j < a.vertices.length; j++) {
+ projected = normal.x * a.vertices[j].x + normal.y * a.vertices[j].y;
+ if (minA === undefined || projected < minA) {
+ minA = projected;
+ }
+
+ if (maxA === undefined || projected > maxA) {
+ maxA = projected;
+ }
+ }
+
+ minB = maxB = undefined;
+ for (let j = 0; j < b.vertices.length; j++) {
+ projected = normal.x * b.vertices[j].x + normal.y * b.vertices[j].y;
+ if (minB === undefined || projected < minB) {
+ minB = projected;
+ }
+
+ if (maxB === undefined || projected > maxB) {
+ maxB = projected;
+ }
+ }
+
+ if (maxA && maxB && minB && minA && (maxA < minB || maxB < minA)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+
+
+
+
+ static fromRectangle = (x: number, y: number, width: number, height: number) => {
+ const poly = new Polygon();
+ // top-left
+ poly.vertices.push(new Vec2(x, y))
+ // top-right
+ poly.vertices.push(new Vec2(x + width, y));
+ // bottom-right
+ poly.vertices.push(new Vec2(x + width, y + height));
+ // bottom-left
+ poly.vertices.push(new Vec2(x, y + height));
+
+ poly.createEdges();
+ return poly;
+ }
+
+ static isPointInside = (point: Vec2, poly: Polygon) => {
+ let inside = false;
+ for (let i = 0, j = poly.vertices.length - 1; i < poly.vertices.length; j = i++) {
+ const pti = poly.vertices[i];
+ const ptj = poly.vertices[j];
+ const intersect = ((pti.y > point.y) !== (ptj.y > point.y)) && (point.x < (ptj.x - pti.x) * (point.x - pti.y) / (ptj.y - pti.y) + pti.x);
+ if (intersect) inside = !inside;
+ }
+
+ return inside;
+ }
+
+ static checkIntersection = (a: Polygon, b: Polygon) => {
+ for (let i = 0; i < a.vertices.length; i++) {
+ if (Polygon.isPointInside(a.vertices[i], b)) return true;
+ }
+
+ return false;
+ }
+
+ static fromNSided = (sides: number, x: number, y: number, r: number, opts?: { invert: boolean }) => {
+ const vertices: Vec2[] = [];
+ const center = new Vec2(x, y);
+ const angle = 360 / sides;
+ const invert = opts?.invert
+ for (let i = 0; i < sides; i++) {
+ const newPoint = Vec2.RotateAbout(center, new Vec2(x, y + (invert ? -r : r)), angle - (angle * i), { degrees: true }).sub(new Vec2(0, invert ? -r : r));
+ vertices.push(newPoint);
+ }
+
+ return new Polygon(vertices);
+ }
+
+ static fromHexagon = (x: number, y: number, r: number) => {
+ const vertices: Vec2[] = []
+ const center = new Vec2(x, y);
+ for (let i = 0; i < 6; i++) {
+ const newPoint = Vec2.RotateAbout(center, new Vec2(x, y + r), 30 - (60 * i), { degrees: true }).sub(new Vec2(0, r));
+ vertices.push(newPoint);
+ }
+ return new Polygon(vertices);
+ }
+
+ static fromTriangle = (x: number, y: number, r: number, opts?: { invert: boolean }) => {
+ const vertices: Vec2[] = [];
+ const center = new Vec2(x, y);
+ let invert = opts?.invert
+ for (let i = 0; i < 3; i++) {
+ const newPoint = Vec2.RotateAbout(center, new Vec2(x, y + (invert ? -r : r)), 120 - (120 * i), { degrees: true }).sub(new Vec2(0, invert ? -r : r));
+ vertices.push(newPoint);
+ }
+ return new Polygon(vertices);
+ }
+
+}
\ No newline at end of file
diff --git a/src/games/utils/vec2.ts b/src/games/utils/vec2.ts
new file mode 100644
index 0000000..e0c0c19
--- /dev/null
+++ b/src/games/utils/vec2.ts
@@ -0,0 +1,303 @@
+import { randInt, random } from "./math";
+
+type RandomOpts = {
+ x?: {
+ onlyPos?: boolean,
+ onlyNeg?: boolean
+ },
+ y?: {
+ onlyPos?: boolean,
+ onlyNeg?: boolean
+ }
+}
+
+export class Vec2{
+ constructor(
+ public x: number = 0,
+ public y: number = 0
+ ){}
+
+
+ /**
+ * Adds a vector to the current vector
+ *
+ * @param {Vec2} v Vector to be added
+ * @returns {this}
+ */
+ public add = (v: Vec2) => {
+ this.x += v.x;
+ this.y += v.y;
+ return this;
+ }
+
+ /**
+ * Subtracts the current vector by another vector
+ *
+ * @param {Vec2} v Vector to subtract
+ * @returns {this}
+ */
+ public sub = (v: Vec2) => {
+ this.x -= v.x;
+ this.y -= v.y;
+ return this;
+ }
+
+ /**
+ * Multiplies the vector by a scalar or another vector (element-wise)
+ *
+ * @public
+ * @param {(number | Vec2)} val Vector or scalar to be multiplied by
+ * @returns {Vec2}
+ */
+ public mult(val: number): Vec2
+ public mult(val: Vec2): Vec2;
+
+ /**
+ * Multiplies the vector by a scalar or another vector (element-wise)
+ *
+ * @public
+ * @param {(number | Vec2)} val Vector or scalar to be multiplied by
+ * @returns {Vec2}
+ */
+ public mult(val: number | Vec2) {
+ if (typeof val === "number") {
+ this.x *= val;
+ this.y *= val;
+ } else {
+ this.x *= val.x;
+ this.y *= val.y;
+ }
+ return this;
+ }
+
+ /**
+ * Calculates the angle of the vector
+ *
+ * @returns {number}
+ */
+ public heading = () => {
+ if (this.x < 0) {
+ return Math.atan(this.y / this.x) + Math.PI;
+ }
+ return Math.atan(this.y / this.x);
+ }
+
+ /**
+ * Calculates the magnitude of the vector
+ *
+ * @returns {number}
+ */
+ public magnitude = () => {
+ return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2));
+ }
+
+ /**
+ * Normalizes the vector (magnitude = 1)
+ */
+ public normalize = () => {
+ const mag = this.magnitude();
+ this.x = parseFloat((this.x / mag).toFixed(2));
+ this.y = parseFloat((this.y / mag).toFixed(2));
+ }
+
+ /**
+ * Limits the vectors magnitude
+ *
+ * @param {number} limiter Amount to limit by
+ */
+ public limit = (limiter: number) => {
+ if (this.magnitude() > limiter) {
+ this.setMagnitude(limiter);
+ }
+ }
+
+ /**
+ * Sets the magnitude of the vector while keeping the same direction
+ *
+ * @param {number} mag Magnitude to be set
+ * @returns {this}
+ */
+ public setMagnitude = (mag: number) => {
+ this.normalize();
+ this.mult(mag);
+ return this;
+ }
+
+ /**
+ * Checks if the this vector is equal to another
+ *
+ * @param {Vec2} v Vector
+ * @returns {boolean}
+ */
+ public equals = (v: Vec2): boolean => {
+ return (v.x === this.x) && (v.y === this.y);
+ }
+
+ /**
+ * Returns a copy of the current vector
+ *
+ * @returns {Vec2}
+ */
+ public copy = (): Vec2 => {
+ return new Vec2(this.x, this.y);
+ }
+
+ /**
+ * Scales the vector by a random scalar in the range
+ * @date 6/27/2023 - 4:51:12 PM
+ *
+ * @param {number} min Min of the range
+ * @param {number} max Max of the range
+ */
+ public randScale = (min: number, max: number) => {
+ const scale = Math.random() * (max - min) + min;
+ this.mult(scale);
+ }
+
+ /**
+ * Checks if the vector is inside given bounds
+ *
+ * @param {{ min: Vec2, max: Vec2 }} bounds Object containing min and max vectors
+ * @param {?{ maxInclusive?: boolean, minInclusive?: boolean }} [opts]
+ * @returns {boolean}
+ */
+ public isInbounds = (bounds: { min: Vec2, max: Vec2 }, opts?: { maxInclusive?: boolean, minInclusive?: boolean }) => {
+ let { min, max } = bounds;
+ if (opts?.maxInclusive) {
+ max = Vec2.ScalarAddition(max, -1)
+ }
+ if (opts?.minInclusive) {
+ min = Vec2.ScalarAddition(min, 1)
+ }
+ if (this.x < min.x || this.x > max.x || this.y < min.y || this.y > max.y) return false;
+ return true;
+ }
+
+ /**
+ * Rotates a vector by a given angle
+ *
+ * @param {number} angle
+ * @param {?{ degrees?: boolean }} [opts]
+ * @returns {Vec2}
+ */
+ public rotate = (angle: number, opts?: { degrees?: boolean }): Vec2 => {
+ const rotateBy = opts?.degrees ? angle * (Math.PI / 180) : angle;
+ const newHeading = this.heading() + rotateBy;
+ const mag = this.magnitude();
+ this.x = Math.cos(newHeading) * mag;
+ this.y = Math.sin(newHeading) * mag;
+ return this;
+ }
+
+ /**
+ * Adds a scalar to both x and y of the vector
+ *
+ * @param {Vec2} v
+ * @param {number} scalar
+ * @returns {Vec2}
+ */
+ static ScalarAddition = (v: Vec2, scalar: number) => {
+ return new Vec2(v.x + scalar, v.y + scalar);
+ }
+
+ /**
+ * Creates a randomly generated vector with integer values
+ *
+ * @param {?{ max?: Vec2, min?: Vec2 }} [opts]
+ * @returns {Vec2}
+ */
+ static RandomInteger = (opts?: { max?: Vec2, min?: Vec2 }) => {
+ let min = new Vec2();
+ let max = new Vec2(Infinity, Infinity);
+ if (opts?.max) {
+ max = opts.max;
+ }
+ if (opts?.min) {
+ min = opts.min;
+ }
+
+ return new Vec2(randInt(min.x, max.x - 1), randInt(min.y, max.y - 1));
+ }
+
+ /**
+ * Creates a randomly generated vector with values in the range [-1, 1]
+ *
+ * @param {?RandomOpts} [opts] Specify to limit the range of x and y to [0, 1], [-1, 0]
+ * @returns {Vec2}
+ */
+ static Random = (opts?: RandomOpts) => {
+ let xAngle = random(0, 2 * Math.PI);
+ let yAngle = random(0, 2 * Math.PI);
+ if (opts?.x){
+ if (opts.x.onlyPos && !opts.x.onlyNeg) xAngle = random(0, (Math.PI / 2))
+ if (opts.x.onlyNeg && !opts.x.onlyPos) xAngle = random(Math.PI / 2, (3 * Math.PI) / 2)
+ }
+
+ if (opts?.y) {
+ if (opts.y.onlyPos && !opts.y.onlyNeg) yAngle = random(0, Math.PI);
+ if (opts.y.onlyNeg && !opts.y.onlyPos) yAngle = random(Math.PI, 2 * Math.PI);
+ }
+
+ // const angle = Math.random() * (2 * Math.PI);
+ return new Vec2(Math.cos(xAngle), Math.sin(yAngle));
+ }
+
+ /**
+ * Calculates the distance between two vectors
+ * @date 6/27/2023 - 4:54:00 PM
+ *
+ * @param {Vec2} a
+ * @param {Vec2} b
+ * @returns {number}
+ */
+ static Distance = (a: Vec2, b: Vec2) => {
+ return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
+ }
+
+
+ /**
+ * Returns the subtraction of two vectors, b - a
+ *
+ * @param {Vec2} a Vector to subtract
+ * @param {Vec2} b Vector to be subtracted
+ */
+ static Subtract = (a: Vec2, b: Vec2) => {
+ return new Vec2(b.x - a.x, b.y - a.y);
+ }
+
+ /**
+ * Returns the addition of two vectors, a + b
+ *
+ * @param {Vec2} a
+ * @param {Vec2} b
+ */
+ static Add = (a: Vec2, b: Vec2) => {
+ return new Vec2(a.x + b.x, a.y + b.y);
+ }
+
+ /**
+ * Creates a vector from an angle
+ *
+ * @param {number} angle Angle in radians
+ * @returns {Vec2}
+ */
+ static FromAngle = (angle: number): Vec2 => {
+ return new Vec2(Math.cos(angle), Math.sin(angle));
+ }
+
+ // static Rotated = (v: Vec2, angle: number, opts?: { degrees: boolean }) => {
+ // const rotateBy = opts?.degrees ? angle * (Math.PI / 180) : angle;
+ // const newHeading = v.heading() + rotateBy;
+ // const mag = v.magnitude();
+ // return new Vec2(Math.cos(newHeading) * mag, Math.sin(newHeading) * mag);
+ // }
+
+ static RotateAbout = (about: Vec2, v: Vec2, angle: number, opts?: { degrees: boolean }) => {
+ const rotateBy = opts?.degrees ? angle * (Math.PI / 180) : angle;
+ const newX = ((v.x - about.x) * Math.cos(rotateBy)) - ((v.y - about.y) * Math.sin(rotateBy)) + about.x;
+ const newY = ((v.x - about.x) * Math.sin(rotateBy)) + ((v.y - about.y) * Math.cos(rotateBy)) + about.y;
+ return new Vec2(newX, newY);
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/pages/Arcade/Arcade.styles.tsx b/src/pages/Arcade/Arcade.styles.tsx
new file mode 100644
index 0000000..1ec2d0e
--- /dev/null
+++ b/src/pages/Arcade/Arcade.styles.tsx
@@ -0,0 +1,56 @@
+import { TextProps } from "@chakra-ui/react";
+
+const title: TextProps = {
+ fontSize: "6xl",
+ fontFamily: "heading",
+ fontWeight: "bold",
+ as: "h1",
+};
+const titleSpan: TextProps = {
+ as: "span",
+ variant: "gradient",
+};
+
+const subtitle: TextProps = {
+ fontSize: "4xl",
+ fontFamily: "heading",
+ as: "h2",
+};
+
+const category: TextProps = {
+ as: "h3",
+ fontSize: "3xl",
+ fontFamily: "heading",
+};
+
+const info: TextProps = {
+ fontSize: "lg",
+};
+
+const postTitle: TextProps = {
+ fontSize: "2xl",
+ fontFamily: "heading",
+ fontWeight: "semibold",
+};
+
+const postDescription: TextProps = {
+ my: 0,
+ fontSize: "sm",
+};
+
+const postInfo: TextProps = {
+ fontSize: "sm",
+ fontWeight: "light",
+ color: "gray.500",
+};
+
+export const styles = {
+ title,
+ titleSpan,
+ category,
+ info,
+ postTitle,
+ postDescription,
+ postInfo,
+ subtitle,
+};
diff --git a/src/pages/Arcade/index.tsx b/src/pages/Arcade/index.tsx
new file mode 100644
index 0000000..d6aa59b
--- /dev/null
+++ b/src/pages/Arcade/index.tsx
@@ -0,0 +1,145 @@
+import React, { useState } from "react"
+import {
+ Text,
+ Box,
+ Wrap,
+ WrapItem,
+ Tag,
+ TagLabel,
+ TagCloseButton,
+ SimpleGrid
+} from "@chakra-ui/react"
+import { styles } from "./Arcade.styles"
+import Card from "@website/components/Card"
+import { games, allTags, Games } from "@website/games"
+
+const Arcade: React.FC = () => {
+ const [filterTags, setFilterTags] = useState>(new Set());
+
+ const addFilterTag = (tag: string) => {
+ setFilterTags((prev) => new Set(prev.add(tag)));
+ }
+
+ const removeFilterTag = (tag: string) => {
+ setFilterTags(
+ (prev) => new Set([...prev.values()].filter((x) => x !== tag))
+ );
+ }
+
+ return (
+ <>
+ Arcade
+ I like to dabble in some simple game development, so, here you can try out some of the games/simulations I've created.
+
+
+ Tags
+
+
+ {
+ allTags.map( (tag, i) => {
+ return (
+
+ addFilterTag(tag)}
+ >
+ {tag}
+
+
+ )
+ })
+ }
+
+
+ {
+ !!filterTags.size && (
+
+
+ Filtered by:
+
+ {
+ [...filterTags.values()].map( tag => {
+ return (
+
+
+ {tag}
+ removeFilterTag(tag)}
+ />
+
+
+ )
+ })
+ }
+
+ )
+ }
+
+ {
+ Object.keys(games)
+ .filter( game => {
+ const metadata = games[game as Games];
+ if (!metadata.tags) return true;
+ const filters = [...filterTags.values()];
+ if (filters.length === 0) return true;
+ for (let i = 0; i < filters.length; i++) {
+ if (metadata.tags.includes(filters[i])) return true;
+ }
+ return false;
+ })
+ .map( game => {
+ const metadata = games[game as Games];
+ return (
+
+
+ {metadata.title}
+ {metadata.description}
+
+ {
+ metadata.tags?.map( tag => {
+ return (
+
+ {tag}
+
+ )
+ })
+ }
+
+
+
+ )
+ })
+ }
+
+ >
+ )
+}
+
+export default Arcade;
\ No newline at end of file
diff --git a/src/pages/Blog/index.tsx b/src/pages/Blog/index.tsx
index 7ae5e33..8a87b0f 100644
--- a/src/pages/Blog/index.tsx
+++ b/src/pages/Blog/index.tsx
@@ -123,7 +123,7 @@ const Blog: React.FC = () => {
})}
)}
-
+
{loading && }
{data &&
sortByDate(data.blogMetadata).map((metadata) => {
@@ -147,7 +147,7 @@ const Blog: React.FC = () => {
key={id}
>
{image && (
-
+
)}
{name}
diff --git a/src/pages/Game/Game.styles.tsx b/src/pages/Game/Game.styles.tsx
new file mode 100644
index 0000000..1ec2d0e
--- /dev/null
+++ b/src/pages/Game/Game.styles.tsx
@@ -0,0 +1,56 @@
+import { TextProps } from "@chakra-ui/react";
+
+const title: TextProps = {
+ fontSize: "6xl",
+ fontFamily: "heading",
+ fontWeight: "bold",
+ as: "h1",
+};
+const titleSpan: TextProps = {
+ as: "span",
+ variant: "gradient",
+};
+
+const subtitle: TextProps = {
+ fontSize: "4xl",
+ fontFamily: "heading",
+ as: "h2",
+};
+
+const category: TextProps = {
+ as: "h3",
+ fontSize: "3xl",
+ fontFamily: "heading",
+};
+
+const info: TextProps = {
+ fontSize: "lg",
+};
+
+const postTitle: TextProps = {
+ fontSize: "2xl",
+ fontFamily: "heading",
+ fontWeight: "semibold",
+};
+
+const postDescription: TextProps = {
+ my: 0,
+ fontSize: "sm",
+};
+
+const postInfo: TextProps = {
+ fontSize: "sm",
+ fontWeight: "light",
+ color: "gray.500",
+};
+
+export const styles = {
+ title,
+ titleSpan,
+ category,
+ info,
+ postTitle,
+ postDescription,
+ postInfo,
+ subtitle,
+};
diff --git a/src/pages/Game/index.tsx b/src/pages/Game/index.tsx
new file mode 100644
index 0000000..b3daacd
--- /dev/null
+++ b/src/pages/Game/index.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { useParams } from "react-router-dom";
+import { Text } from "@chakra-ui/react";
+import { styles } from "./Game.styles";
+import { games } from "@website/games";
+import type { Games } from "@website/games";
+
+const Game: React.FC = () => {
+ const { game } = useParams();
+ const metadata = games[game as Games];
+ if (metadata){
+ const Component = metadata.component
+ return (
+ <>
+ {metadata.title}
+ {metadata.description}
+
+ >
+ )
+ } else {
+ return (
+ <>
+ Cannot find game: {game}
+ >
+ )
+ }
+
+}
+
+export default Game;
\ No newline at end of file
diff --git a/src/pages/Home/Hero.styles.ts b/src/pages/Home/Hero.styles.ts
index 1d050c2..e42226a 100644
--- a/src/pages/Home/Hero.styles.ts
+++ b/src/pages/Home/Hero.styles.ts
@@ -1,7 +1,7 @@
import type { TextProps, LinkProps } from "@chakra-ui/react";
const mainText: TextProps = {
- fontSize: { base: "6xl", md: "8xl" },
+ fontSize: { base: "5xl", md: "7xl" },
fontWeight: "bold",
textAlign: "right",
lineHeight: "none",
@@ -10,6 +10,14 @@ const mainText: TextProps = {
mb: 1,
};
+const mainCaptionText: TextProps = {
+ fontSize: { base: "4xl", md: "6xl" },
+ fontWeight: "bold",
+ fontFamily: "heading",
+ lineHeight: "none",
+ textAlign: "right",
+}
+
const subText: TextProps = {
as: "h4",
fontSize: { base: "xl", lg: "2xl" },
@@ -31,4 +39,5 @@ export const styles = {
mainText,
subText,
subTextLink,
+ mainCaptionText
};
diff --git a/src/pages/Home/Hero.tsx b/src/pages/Home/Hero.tsx
index e1fac3f..eefe279 100644
--- a/src/pages/Home/Hero.tsx
+++ b/src/pages/Home/Hero.tsx
@@ -20,7 +20,7 @@ const Hero: React.FC = () => {
Hi"]}
+ greetings={["Hello", "اَسَّلاَمُ عَلَيْكُم", "Hi"]}
captions={["Ammar", "a Muslim", "an Engineer"]}
emojis={["👋🏽", "🕌", "🛠️"]}
animations={["wave", "fadeIn", "fadeIn"]}
@@ -34,7 +34,7 @@ const Hero: React.FC = () => {
University of Waterloo
-
+ {/*
Frontend Developer{" "}
@
@@ -42,7 +42,7 @@ const Hero: React.FC = () => {
AI Arena
-
+ */}
{textBoxDim && }
diff --git a/src/theme.ts b/src/theme.ts
index 991a880..1f694cc 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -89,6 +89,16 @@ const components = {
},
},
},
+ Popover: {
+ variants: {
+ picker: {
+ popper: {
+ maxWidth: "unset",
+ width: "unset"
+ }
+ }
+ }
+ }
};
const theme = extendTheme({
|