diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 88e26f2..5dde5b2 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { MutableRefObject, useState } from "react"; import { GlobalContext } from "../../contexts"; import World from "../World"; import Player from "../Player"; @@ -12,6 +12,7 @@ import House from "../House"; import Fire from "../Fire"; import GameOver from "../GameOver"; import { GAME_STATES, MAX_HEALTH } from "../../constants"; +import { Collider } from "../../utils"; import "./style.css"; /* @@ -21,6 +22,7 @@ import "./style.css"; */ export default function App() { const [gameState, setGameState] = useState(GAME_STATES.Game); + const [colliders, setColliders] = useState[]>([]); const [isCellarDoorOpen, setIsCellarDoorOpen] = useState(false); const [isLeverUsed, setIsLeverUsed] = useState(false); const [playerHealth, setPlayerHealth] = useState(MAX_HEALTH); @@ -28,17 +30,19 @@ export default function App() { return (
{gameState === GAME_STATES.GameOver && } - + = ({ left, top }) => { const canvasRef = useRef(null); + const collider = useRef( + new Collider(new Rect(left, top, WIDTH, HEIGHT), ColliderType.Bonus) + ); + + useColliders(collider); useAnimatedSprite({ canvasRef, @@ -18,8 +26,8 @@ const Coin: FC = ({ left, top }) => { tileSet: TILE_SETS.Objects, width: WIDTH, height: HEIGHT, - tileX: 0, - tileY: 128, + tileX: TILE_X, + tileY: TILE_Y, animationLength: 3, animationSpeed: 100, }); diff --git a/src/components/Fire/index.tsx b/src/components/Fire/index.tsx index a90ae86..21c7d60 100644 --- a/src/components/Fire/index.tsx +++ b/src/components/Fire/index.tsx @@ -1,15 +1,23 @@ import { useRef, FC } from "react"; import { TILE_SIZE, TILE_SETS } from "../../constants"; -import { useAnimatedSprite } from "../../hooks"; +import { useAnimatedSprite, useColliders } from "../../hooks"; +import { Collider, ColliderType, Rect } from "../../utils"; import "./style.css"; const WIDTH = TILE_SIZE; const HEIGHT = TILE_SIZE; +const TILE_X = 130; +const TILE_Y = 98; type FireProps = { left: number; top: number }; const Fire: FC = ({ left, top }) => { const canvasRef = useRef(null); + const collider = useRef( + new Collider(new Rect(left, top, WIDTH, HEIGHT), ColliderType.Damage) + ); + + useColliders(collider); useAnimatedSprite({ canvasRef, @@ -18,8 +26,8 @@ const Fire: FC = ({ left, top }) => { tileSet: TILE_SETS.Objects, width: WIDTH, height: HEIGHT, - tileX: 130, - tileY: 98, + tileX: TILE_X, + tileY: TILE_Y, animationLength: 6, animationSpeed: 125, }); diff --git a/src/components/Heart/index.tsx b/src/components/Heart/index.tsx index cb7db3e..cefdd07 100644 --- a/src/components/Heart/index.tsx +++ b/src/components/Heart/index.tsx @@ -1,25 +1,48 @@ -import { useRef, FC } from "react"; +import { useRef, FC, useState } from "react"; import { TILE_SIZE, TILE_SETS } from "../../constants"; -import { useAnimatedSprite } from "../../hooks"; +import { useAnimatedSprite, useColliders } from "../../hooks"; +import { + Collider, + ColliderType, + getRandomPosition, + Rect, + Vector, +} from "../../utils"; import "./style.css"; const WIDTH = TILE_SIZE; const HEIGHT = TILE_SIZE; +const TILE_X = 0; +const TILE_Y = 96; type HeartProps = { left: number; top: number }; const Heart: FC = ({ left, top }) => { + const [position, setPosition] = useState(new Vector(left, top)); const canvasRef = useRef(null); + const updatePosition = (c: Collider) => { + const newPosition = getRandomPosition(WIDTH, HEIGHT); + setPosition(newPosition); + c.rect.moveTo(newPosition.x, newPosition.y); + }; + const collider = new Collider( + new Rect(position.x, position.y, WIDTH, HEIGHT), + ColliderType.Health, + updatePosition + ); + const colliderRef = useRef(collider); + + useColliders(colliderRef); useAnimatedSprite({ canvasRef, - left, - top, + left: position.x, + top: position.y, tileSet: TILE_SETS.Objects, width: WIDTH, height: HEIGHT, - tileX: 0, - tileY: 96, + tileX: TILE_X, + tileY: TILE_Y, animationLength: 3, animationSpeed: 75, }); diff --git a/src/components/Player/constants.ts b/src/components/Player/constants.ts index 9150c3d..623f1c3 100644 --- a/src/components/Player/constants.ts +++ b/src/components/Player/constants.ts @@ -4,6 +4,7 @@ export const TILE_X = 0; export const TILE_Y = 8; export const ANIMATION_LENGTH = 3; export const SPEED = 4; +export const KNOCKBACK = SPEED * 12; export const Input = { Interact: [" ", "Enter"], diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx index 7df6769..1a88b7c 100644 --- a/src/components/Player/index.tsx +++ b/src/components/Player/index.tsx @@ -1,41 +1,35 @@ import { useEffect, useRef, FC, useContext } from "react"; -import { GAME_STATES, TILE_SETS } from "../../constants"; +import { + GAME_STATES, + MAX_HEALTH, + MIN_HEALTH, + TILE_SETS, +} from "../../constants"; import { GlobalContext } from "../../contexts"; -import { Vector } from "../../utils"; +import { ColliderType, Rect, Vector } from "../../utils"; import { ANIMATION_LENGTH, HEIGHT, Input, WIDTH } from "./constants"; -import { drawFrame, getInputVector, move } from "./utils"; +import { drawFrame, getInputVector, walk, knockback } from "./utils"; import "./style.css"; type PlayerProps = { top: number; left: number; onInteract: (isOpen: boolean | ((wasOpen: boolean) => boolean)) => void; - onCollision: (health: number | ((prev: number) => number)) => void; }; /* * TODO: - * - move object specific interactions outside of Player * - move player controls to global context * - use input loop to remove keydown delay - * - create util function for collisions */ let invulnerable = false; -const Player: FC = ({ onInteract, onCollision, top, left }) => { +const Player: FC = ({ onInteract, top, left }) => { const canvasRef = useRef(null); - const { setGameState, playerHealth } = useContext(GlobalContext); + const playerRect = useRef(new Rect(left, top, WIDTH, HEIGHT)); + const { setGameState, playerHealth, setPlayerHealth, colliders } = + useContext(GlobalContext); useEffect(() => { - const fireCanvas = document.getElementById( - "fire-canvas" - ) as HTMLCanvasElement | null; - const heartCanvas = document.getElementById( - "heart-canvas" - ) as HTMLCanvasElement | null; - const coinCanvas = document.getElementById( - "coin-canvas" - ) as HTMLCanvasElement | null; - const ctx = canvasRef.current?.getContext("2d"); if (!canvasRef.current || !ctx) { return; @@ -64,82 +58,32 @@ const Player: FC = ({ onInteract, onCollision, top, left }) => { return; } - if (playerHealth > 0) { - if (playerHealth < 4) { - if ( - heartCanvas && - parseInt(canvasRef.current.style.left || "0") + 6 <= - parseInt(heartCanvas.style.left || "0") + 16 && - parseInt(canvasRef.current.style.left || "0") + 36 >= - parseInt(heartCanvas.style.left || "0") && - parseInt(canvasRef.current.style.top || "0") + 36 <= - parseInt(heartCanvas.style.top || "0") + 32 && - parseInt(canvasRef.current.style.top || "0") + 36 >= - parseInt(heartCanvas.style.top || "0") + 16 - ) { - onCollision((playerHealth) => Math.min(4, playerHealth + 1)); - heartCanvas.remove(); - } + colliders.forEach((collider) => { + if (!collider.current.rect.overlaps(playerRect.current)) { + return; } if ( - coinCanvas && - parseInt(canvasRef.current.style.left || "0") + 6 <= - parseInt(coinCanvas.style.left || "0") + 16 && - parseInt(canvasRef.current.style.left || "0") + 36 >= - parseInt(coinCanvas.style.left || "0") && - parseInt(canvasRef.current.style.top || "0") + 36 <= - parseInt(coinCanvas.style.top || "0") + 32 && - parseInt(canvasRef.current.style.top || "0") + 36 >= - parseInt(coinCanvas.style.top || "0") + 16 + collider.current.type === ColliderType.Health && + playerHealth < MAX_HEALTH ) { - coinCanvas.remove(); - } - - if (Input.Interact.includes(event.key)) { - onInteract((wasOpen) => !wasOpen); - } + collider.current.onCollision(); + setPlayerHealth(Math.min(MAX_HEALTH, playerHealth + 1)); + } else if (collider.current.type === ColliderType.Bonus) { + collider.current.onCollision(); + // TODO + } else if ( + collider.current.type === ColliderType.Damage && + !invulnerable + ) { + collider.current.onCollision(); - direction = getInputVector(event.key); - move(direction, canvasRef.current); + const velocity = knockback(direction, canvasRef.current!); + playerRect.current.moveBy(velocity.x, velocity.y); - if ( - fireCanvas && - !invulnerable && - parseInt(canvasRef.current.style.left || "0") + 6 <= - parseInt(fireCanvas.style.left || "0") + 16 && - parseInt(canvasRef.current.style.left || "0") + 36 >= - parseInt(fireCanvas.style.left || "0") && - parseInt(canvasRef.current.style.top || "0") + 36 <= - parseInt(fireCanvas.style.top || "0") + 32 && - parseInt(canvasRef.current.style.top || "0") + 36 >= - parseInt(fireCanvas.style.top || "0") + 16 - ) { - if (event.key === "w" || event.key === "ArrowUp") { - canvasRef.current.style.top = `${Math.min( - window.innerHeight, - parseInt(canvasRef.current.style.top || "0") + 48 - )}px`; - } else if (event.key === "s" || event.key === "ArrowDown") { - canvasRef.current.style.top = `${Math.max( - 0, - parseInt(canvasRef.current.style.top || "0") - 48 - )}px`; - } else if (event.key === "a" || event.key === "ArrowLeft") { - canvasRef.current.style.left = `${Math.min( - window.innerWidth, - parseInt(canvasRef.current.style.left || "0") + 48 - )}px`; - } else if (event.key === "d" || event.key === "ArrowRight") { - canvasRef.current.style.left = `${Math.max( - 0, - parseInt(canvasRef.current.style.left || "0") - 48 - )}px`; - } - - onCollision((playerHealth) => Math.max(0, playerHealth - 1)); + setPlayerHealth(Math.max(MIN_HEALTH, playerHealth - 1)); invulnerable = true; - canvasRef.current.style.filter = "brightness(6)"; + canvasRef.current!.style.filter = "brightness(6)"; const interval = setInterval(() => { if (!canvasRef.current) { @@ -161,23 +105,42 @@ const Player: FC = ({ onInteract, onCollision, top, left }) => { invulnerable = false; }, 1500); } + }); - if (!keyPressed) { - keyPressed = true; - drawFrame(ctx, tileSet, direction, currentFrame); - - setTimeout(() => { - keyPressed = false; - currentFrame = - currentFrame === ANIMATION_LENGTH ? 0 : currentFrame + 1; - }, 125); - } - } else { + if (playerHealth <= MIN_HEALTH) { setGameState(GAME_STATES.GameOver); + return; + } + + if (Input.Interact.includes(event.key)) { + onInteract((wasOpen) => !wasOpen); + } + + direction = getInputVector(event.key); + const velocity = walk(direction, canvasRef.current); + playerRect.current.moveBy(velocity.x, velocity.y); + + if (!keyPressed) { + keyPressed = true; + drawFrame(ctx, tileSet, direction, currentFrame); + + setTimeout(() => { + keyPressed = false; + currentFrame = + currentFrame === ANIMATION_LENGTH ? 0 : currentFrame + 1; + }, 125); } }; }; - }, [onInteract, onCollision, playerHealth, setGameState, top, left]); + }, [ + onInteract, + setPlayerHealth, + playerHealth, + setGameState, + top, + left, + colliders, + ]); return ( <> diff --git a/src/components/Player/utils.ts b/src/components/Player/utils.ts index da30b6f..439c516 100644 --- a/src/components/Player/utils.ts +++ b/src/components/Player/utils.ts @@ -1,6 +1,14 @@ import { Vector } from "../../utils"; import { TILE_SIZE } from "../../constants"; -import { Input, WIDTH, HEIGHT, TILE_X, TILE_Y, SPEED } from "./constants"; +import { + Input, + WIDTH, + HEIGHT, + TILE_X, + TILE_Y, + SPEED, + KNOCKBACK, +} from "./constants"; export const getSpritePos = (direction: Vector, currentFrame: number) => { let yMultiplier = 0; @@ -45,11 +53,26 @@ export const getInputVector = (key: string) => { return Vector.Zero; }; -export const move = (direction: Vector, canvas: HTMLCanvasElement) => { +const move = (velocity: Vector, canvas: HTMLCanvasElement) => { + canvas.style.top = `${parseInt(canvas.style.top || "0") + velocity.y}px`; + canvas.style.left = `${parseInt(canvas.style.left || "0") + velocity.x}px`; +}; + +export const walk = (direction: Vector, canvas: HTMLCanvasElement) => { if (direction.eq(Vector.Zero)) { - return; + return Vector.Zero; } + const velocity = direction.mul(SPEED); - canvas.style.top = `${parseInt(canvas.style.top || "0") - velocity.x}px`; - canvas.style.left = `${parseInt(canvas.style.left || "0") + velocity.y}px`; + move(velocity, canvas); + + return velocity; +}; + +export const knockback = (direction: Vector, canvas: HTMLCanvasElement) => { + const velocity = direction.mul(-KNOCKBACK); + canvas.style.top = `${parseInt(canvas.style.top || "0") + velocity.y}px`; + canvas.style.left = `${parseInt(canvas.style.left || "0") + velocity.x}px`; + + return velocity; }; diff --git a/src/contexts/global.ts b/src/contexts/global.ts index 3c7aa8f..8686d53 100644 --- a/src/contexts/global.ts +++ b/src/contexts/global.ts @@ -1,14 +1,25 @@ -import { createContext } from "react"; +import { createContext, MutableRefObject } from "react"; import { GAME_STATES, MAX_HEALTH } from "../constants"; +import { Collider, noop } from "../utils"; export type GlobalContextType = { readonly gameState: GAME_STATES; - setGameState(newState: GAME_STATES): void; - playerHealth: number; + setGameState: (newState: GAME_STATES) => void; + readonly playerHealth: number; + setPlayerHealth: (health: number) => void; + readonly colliders: MutableRefObject[]; + setColliders: ( + value: ( + prevValue: MutableRefObject[] + ) => MutableRefObject[] + ) => void; }; export const GlobalContext = createContext({ gameState: GAME_STATES.Game, - setGameState: () => {}, + setGameState: noop, playerHealth: MAX_HEALTH, + setPlayerHealth: noop, + colliders: [], + setColliders: noop, }); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a2479c2..1f08d62 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./useSprite"; export * from "./useAnimatedSprite"; +export * from "./useColliders"; diff --git a/src/hooks/useColliders.ts b/src/hooks/useColliders.ts new file mode 100644 index 0000000..c52916c --- /dev/null +++ b/src/hooks/useColliders.ts @@ -0,0 +1,11 @@ +import { useContext, useEffect, MutableRefObject } from "react"; +import { GlobalContext } from "../contexts"; +import { Collider } from "../utils"; + +export const useColliders = (collider: MutableRefObject) => { + const { setColliders } = useContext(GlobalContext); + + useEffect(() => { + setColliders((colliders) => [...colliders, collider]); + }, [collider, setColliders]); +}; diff --git a/src/index.tsx b/src/index.tsx index a20898d..62649a7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,8 +6,4 @@ import App from "./components/App"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement ); -root.render( - - - -); +root.render(); diff --git a/src/utils/collider.ts b/src/utils/collider.ts new file mode 100644 index 0000000..60b9105 --- /dev/null +++ b/src/utils/collider.ts @@ -0,0 +1,23 @@ +import { Rect, noop } from "./"; + +export enum ColliderType { + Health, + Bonus, + Damage, +} + +export class Collider { + public readonly rect: Rect; + public readonly type: ColliderType; + public readonly onCollision: () => void; + + constructor( + rect: Rect, + type: ColliderType, + onCollision?: (collider: Collider) => void + ) { + this.rect = rect; + this.type = type; + this.onCollision = () => (onCollision ? onCollision(this) : noop()); + } +} diff --git a/src/utils/getRandomPosition.ts b/src/utils/getRandomPosition.ts new file mode 100644 index 0000000..e77c779 --- /dev/null +++ b/src/utils/getRandomPosition.ts @@ -0,0 +1,9 @@ +import { Vector } from "."; +import { WORLD_WIDTH, WORLD_HEIGHT } from "../constants"; + +export const getRandomPosition = (width: number, height: number) => { + return new Vector( + Math.random() * (WORLD_WIDTH - width + 1), + Math.random() * (WORLD_HEIGHT - height + 1) + ); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 4ad8809..e64a3ad 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,5 @@ export * from "./vector"; +export * from "./rect"; +export * from "./collider"; +export * from "./noop"; +export * from "./getRandomPosition"; diff --git a/src/utils/noop.ts b/src/utils/noop.ts new file mode 100644 index 0000000..f2d5e31 --- /dev/null +++ b/src/utils/noop.ts @@ -0,0 +1 @@ +export const noop = () => {}; diff --git a/src/utils/rect.ts b/src/utils/rect.ts new file mode 100644 index 0000000..fb6c0bf --- /dev/null +++ b/src/utils/rect.ts @@ -0,0 +1,37 @@ +export class Rect { + public x1: number; + public y1: number; + public x2: number; + public y2: number; + public width: number; + public height: number; + + constructor(x: number, y: number, width: number, height: number) { + this.x1 = x; + this.y1 = y; + this.x2 = x + width; + this.y2 = y + height; + this.width = width; + this.height = height; + } + + public overlaps(r: Rect): boolean { + return ( + this.x1 <= r.x2 && this.x2 >= r.x1 && this.y1 <= r.y2 && this.y2 >= r.y1 + ); + } + + public moveBy(dx: number, dy: number) { + this.x1 += dx; + this.y1 += dy; + this.x2 = this.x1 + this.width; + this.y2 = this.y1 + this.height; + } + + public moveTo(x: number, y: number) { + this.x1 = x; + this.y1 = y; + this.x2 = this.x1 + this.width; + this.y2 = this.y1 + this.height; + } +} diff --git a/src/utils/vector.ts b/src/utils/vector.ts index 8a0b15b..dcce9f5 100644 --- a/src/utils/vector.ts +++ b/src/utils/vector.ts @@ -20,18 +20,18 @@ export class Vector { } public static get Up() { - return new Vector(1, 0); + return new Vector(0, -1); } public static get Down() { - return new Vector(-1, 0); + return new Vector(0, 1); } public static get Left() { - return new Vector(0, -1); + return new Vector(-1, 0); } public static get Right() { - return new Vector(0, 1); + return new Vector(1, 0); } }