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 ( + + + + + + + + + ) +}; + +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({