From fa07df84525afc799be50b608c151878a5c0baf5 Mon Sep 17 00:00:00 2001 From: Adrien Burgun Date: Sat, 22 Jul 2023 15:27:40 +0200 Subject: [PATCH] :sparkles: Add animations, clean up Game.tsx --- public/tile-laser.svg | 10 +- src/components/Game.module.css | 8 + src/components/Game.tsx | 241 +++++++++++---------------- src/components/Game/useAnimation.ts | 100 +++++++++++ src/components/Game/useBoardMouse.ts | 54 ++++++ src/components/Game/useDrawBoard.ts | 102 ++++++++++++ src/game.ts | 130 +++++++++++++-- 7 files changed, 478 insertions(+), 167 deletions(-) create mode 100644 src/components/Game/useAnimation.ts create mode 100644 src/components/Game/useBoardMouse.ts create mode 100644 src/components/Game/useDrawBoard.ts diff --git a/public/tile-laser.svg b/public/tile-laser.svg index e8bc12d..dc50ba6 100644 --- a/public/tile-laser.svg +++ b/public/tile-laser.svg @@ -24,13 +24,13 @@ inkscape:deskcolor="#505050" inkscape:document-units="px" showgrid="false" - inkscape:zoom="8" - inkscape:cx="69.3125" - inkscape:cy="124.9375" + inkscape:zoom="2.8284271" + inkscape:cx="44.017398" + inkscape:cy="100.58594" inkscape:window-width="1920" - inkscape:window-height="986" + inkscape:window-height="1058" inkscape:window-x="0" - inkscape:window-y="72" + inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1" /> (); + const [width, setWidth] = createSignal(0); + const [height, setHeight] = createSignal(0); + const client = Client({ game: game.AcrossTheHex, multiplayer: props.solo ? Local() : SocketIO({ @@ -45,42 +54,21 @@ export default function Game(props: GameProps) { cells: {}, moves: {} }); + const [turn, setTurn] = createSignal(1); const [soloTurn, setSoloTurn] = createSignal(""); const [stage, setStage] = createSignal(); - const cells = createMemo(() => { - if ("cells" in state && props.playerID) { - return game.getLocalState(state, props.playerID); - } else if ("cells" in state) { - return state.cells; - } else { - return {}; - } - }); + const { + start: startAnimation, + running: animationRunning, + get: getAnimationState, + } = useAnimation(state, 250); + const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null); const [selectedTool, setSelectedTool] = createSignal({ type: "placeBuilding", building: "road" }); - const [tileImages] = createResource(loadTileImages); - - const [canvas, setCanvas] = createSignal(); - - const [width, setWidth] = createSignal(0); - const [height, setHeight] = createSignal(0); - - const targetTiles = createMemo(() => { - const tool = selectedTool(); - if (tool.type !== "attack" || !tool.selected || !props.playerID) return []; - - const building = game.getCell(state.cells, tool.selected.x, tool.selected.y); - if (building?.owner !== props.playerID) return []; - const buildingRules = game.Buildings[building.building]; - if (!("attack" in buildingRules)) return []; - - return buildingRules.attack.targets(state.cells, tool.selected.x, tool.selected.y) - .filter(([x, y]) => game.cellIn(state.cells, x, y)); - }); const moves = () => client.moves as game.Moves; @@ -98,94 +86,79 @@ export default function Game(props: GameProps) { } ); - function drawBoard() { - const ctx = canvas()?.getContext("2d"); - const images = tileImages(); - if (!ctx || !("cells" in state) || !images) return; + const drawBoard = useDrawBoard({ + canvas, + state, + getTransform, + hoveredCell, + selectedTool, + getAnimationState, + playerID: props.playerID, + targetTiles: createMemo(() => { + const tool = selectedTool(); + if (tool.type !== "attack" || !tool.selected || !props.playerID) return []; - ctx.imageSmoothingQuality = "high"; + const building = game.getCell(state.cells, tool.selected.x, tool.selected.y); + if (building?.owner !== props.playerID) return []; + const buildingRules = game.Buildings[building.building]; + if (!("attack" in buildingRules)) return []; - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - const transform = getTransform(); - const transformedCtx = new draw.TransformedCanvas2DCtx(ctx, transform); + return buildingRules.attack.targets(state.cells, tool.selected.x, tool.selected.y) + .filter(([x, y]) => game.cellIn(state.cells, x, y)); + }), + }); + + useBoardMouse({ + canvas, + state, + setHoveredCell, + getTransform, + clickTile(x: number, y: number) { + if (!props.playerID) return; + if (animationRunning()) return; - if (hoveredCell()) { - const [x, y] = hoveredCell()!; const tool = selectedTool(); - let valid = true; - if (props.playerID) { - if (tool.type === "placeBuilding") { - valid = game.canPlaceBuilding( - state.cells, - game.remainingResources(state.cells, state.resources, state.moves, props.playerID), - props.playerID, - x, - y, - tool.building - ); - } else if (tool.type === "attack" && tool.selected) { - valid = !!targetTiles().find(([x2, y2]) => x2 === x && y2 === y); - } else if (tool.type === "attack" && !tool.selected) { - valid = game.canAttackFrom( + if (tool.type === "placeBuilding") { + moves().placeBuilding?.(x, y, tool.building); + } else if (tool.type === "attack") { + if (tool.selected) { + if (game.canAttack( state.cells, game.remainingResources(state.cells, state.resources, state.moves, props.playerID), props.playerID, + tool.selected.x, + tool.selected.y, x, y - ); - } - } - draw.drawCellOutline(transformedCtx, x, y, valid); - } - - for (const [x, y, cell] of game.iterateCells(cells())) { - draw.drawCell(transformedCtx, cell, x, y, images); - } - - for (const [x, y] of targetTiles()) { - draw.drawCellTarget(transformedCtx, x, y, images); - } - } - - function clickTile(x: number, y: number) { - if (!props.playerID) return; - - const tool = selectedTool(); - - if (tool.type === "placeBuilding") { - moves().placeBuilding?.(x, y, tool.building); - } else if (tool.type === "attack") { - if (tool.selected) { - if (game.canAttack( - state.cells, - game.remainingResources(state.cells, state.resources, state.moves, props.playerID), - props.playerID, - tool.selected.x, - tool.selected.y, - x, - y - )) { - moves().attack?.(tool.selected.x, tool.selected.y, x, y); + )) { + moves().attack?.(tool.selected.x, tool.selected.y, x, y); + setSelectedTool({ + type: "attack", + selected: null + }); + } + } else { setSelectedTool({ type: "attack", - selected: null + selected: { + x, + y + } }); } - } else { - setSelectedTool({ - type: "attack", - selected: { - x, - y - } - }); } - } - } + }, + }); + client.start(); client.subscribe((state) => { if (state) { + if (state.ctx.turn > turn()) { + setTurn(state.ctx.turn); + startAnimation(); + } + // For some ungodly reason, I need to clone the state, because otherwise the frozenness sporadically gets // saved in the store, triggering various issues later down the line. // My guess for as to why is that `state.G` can be mutated and some of its properties are passed to Object.freeze @@ -199,6 +172,7 @@ export default function Game(props: GameProps) { .sort((a, b) => +b - +a); setSoloTurn(activePlayers[activePlayers.length - 1] ?? "0"); } + console.log(state.ctx.activePlayers); } }); @@ -206,47 +180,6 @@ export default function Game(props: GameProps) { drawBoard(); }); - createEffect(() => { - const c = canvas(); - - if (!c) return; - - function getTileBelowMouse(event: MouseEvent): [number, number] | null { - const transform = getTransform(); - const bounds = c!.getBoundingClientRect(); - - const x = ((event.clientX - bounds.left) / window.devicePixelRatio - transform.sx) / transform.scale; - const y = ((event.clientY - bounds.top) / window.devicePixelRatio - transform.sy) / transform.scale; - - const [hexX, hexY] = draw.inverseHexPosition(x, y); - if ("cells" in state && game.cellIn(state.cells, hexX, hexY)) { - return [hexX, hexY]; - } else { - return null; - } - } - - function mouseDown(event: MouseEvent) { - const tile = getTileBelowMouse(event); - if (tile) { - clickTile(tile[0], tile[1]); - } - } - - function mouseMove(event: MouseEvent) { - const tile = getTileBelowMouse(event); - setHoveredCell(tile); - } - - c.addEventListener("mousedown", mouseDown); - c.addEventListener("mousemove", mouseMove); - c.addEventListener("mouseleave", () => setHoveredCell(null)); - onCleanup(() => { - c.removeEventListener("mousedown", mouseDown); - c.removeEventListener("mousemove", mouseMove); - }); - }); - return
@@ -262,6 +195,9 @@ export default function Game(props: GameProps) { height: "min(900px, 50vw, 80vh - 13em)", }} /> +
+ Turn {turn()} +
{(matchID) =>
} @@ -274,7 +210,16 @@ export default function Game(props: GameProps) { setSelectedTool={setSelectedTool} resources={() => "resources" in state ? game.remainingResources(state.cells, state.resources, state.moves, playerID()) : 0} resourceGain={() => "cells" in state ? game.getResourceGain(state.cells, playerID()) : 0} - setReady={() => moves().setReady?.()} + setReady={() => { + if (animationRunning()) return; + moves().setReady?.(); + if (selectedTool().type === "attack") { + setSelectedTool({ + type: "attack", + selected: null + }); + } + }} ready={() => stage() === "ready"} />
; }} diff --git a/src/components/Game/useAnimation.ts b/src/components/Game/useAnimation.ts new file mode 100644 index 0000000..5eaddd7 --- /dev/null +++ b/src/components/Game/useAnimation.ts @@ -0,0 +1,100 @@ +import { createEffect, createSignal, onCleanup } from "solid-js"; +import { applyMovesGenerator, type State } from "../../game.ts"; +import { unwrap } from "solid-js/store"; +import clone from "../../clone.ts"; + +export type AnimationState = { + running: true, + state: T +} | { + running: false, + state: undefined +}; + +export function useAnimationQueue( + delay: number = 1000, + callback: (animationState: AnimationState) => void, +) { + const generatorQueue: Generator[] = []; + const [running, setRunning] = createSignal(false); + + let interval: ReturnType | undefined = undefined; + + function scheduler() { + if (generatorQueue.length === 0) { + clearInterval(interval); + interval = undefined; + setRunning(false); + callback({ + running: false, + state: undefined, + }); + } else { + const generator = generatorQueue[0]!; + const next = generator.next(); + + if (next.done) { + generatorQueue.shift(); + } + + if (next.value !== undefined) { + callback({ + running: true, + state: next.value + }); + } else { + setImmediate(scheduler); + } + } + } + + onCleanup(() => { + if (interval) { + clearInterval(interval); + interval = undefined; + setRunning(false); + } + }); + + return { + start(generator: Generator) { + generatorQueue.push(generator); + + if (!interval) { + interval = setInterval(scheduler, delay); + setRunning(true); + } + }, + running, + }; +} + +export default function useAnimation( + state: State, + delay: number, +) { + const [temporaryState, setTemporaryState] = createSignal(); + const { + start, + running, + } = useAnimationQueue(delay, (animationState) => { + setTemporaryState(clone(animationState.state)); + }); + + return { + start() { + const stateClone = clone(unwrap(state)); + const generator: Generator = applyMovesGenerator(stateClone, true); + setTemporaryState(clone(stateClone)); + start(generator); + }, + running, + state: temporaryState, + get(): AnimationState { + return { + running: running(), + state: temporaryState() + } as AnimationState; + }, + } +} diff --git a/src/components/Game/useBoardMouse.ts b/src/components/Game/useBoardMouse.ts new file mode 100644 index 0000000..b84be22 --- /dev/null +++ b/src/components/Game/useBoardMouse.ts @@ -0,0 +1,54 @@ +import { createEffect, type Accessor, onCleanup } from "solid-js"; +import * as draw from "./draw.ts"; +import * as game from "../../game.ts"; + +export type UseBoardMouse = { + canvas: Accessor, + state: game.State, + getTransform: Accessor, + setHoveredCell: (cell: [x: number, y: number] | null) => void, + clickTile: (x: number, y: number) => void, +}; + +export default function useBoardMouse(options: UseBoardMouse) { + createEffect(() => { + const c = options.canvas(); + + if (!c) return; + + function getTileBelowMouse(event: MouseEvent): [number, number] | null { + const transform = options.getTransform(); + const bounds = c!.getBoundingClientRect(); + + const x = ((event.clientX - bounds.left) / window.devicePixelRatio - transform.sx) / transform.scale; + const y = ((event.clientY - bounds.top) / window.devicePixelRatio - transform.sy) / transform.scale; + + const [hexX, hexY] = draw.inverseHexPosition(x, y); + if (game.cellIn(options.state.cells, hexX, hexY)) { + return [hexX, hexY]; + } else { + return null; + } + } + + function mouseDown(event: MouseEvent) { + const tile = getTileBelowMouse(event); + if (tile) { + options.clickTile(tile[0], tile[1]); + } + } + + function mouseMove(event: MouseEvent) { + const tile = getTileBelowMouse(event); + options.setHoveredCell(tile); + } + + c.addEventListener("mousedown", mouseDown); + c.addEventListener("mousemove", mouseMove); + c.addEventListener("mouseleave", () => options.setHoveredCell(null)); + onCleanup(() => { + c.removeEventListener("mousedown", mouseDown); + c.removeEventListener("mousemove", mouseMove); + }); + }); +} diff --git a/src/components/Game/useDrawBoard.ts b/src/components/Game/useDrawBoard.ts new file mode 100644 index 0000000..c70840e --- /dev/null +++ b/src/components/Game/useDrawBoard.ts @@ -0,0 +1,102 @@ +import { createMemo, type Accessor, type Resource, createResource } from "solid-js"; +import * as game from "../../game.js"; +import { loadTileImages, type TileImages } from "../tiles.js"; +import * as draw from "./draw.js"; +import type { Tool } from "../Toolbar.js"; +import type { AnimationState } from "./useAnimation.ts"; + +export type UseDrawBoardOptions = { + canvas: Accessor, + state: game.State, + getTransform: Accessor, + hoveredCell: Accessor<[x: number, y: number] | null>, + selectedTool: Accessor, + getAnimationState: Accessor>, + playerID?: string | undefined, + targetTiles: Accessor<[x: number, y: number][]>, +}; + +export default function useDrawBoard(options: UseDrawBoardOptions) { + const { + canvas, + state, + getTransform, + hoveredCell, + selectedTool, + getAnimationState, + playerID, + targetTiles + } = options; + + const [tileImages] = createResource(loadTileImages); + + const cells = createMemo(() => { + const animationState = getAnimationState(); + if (animationState.running) { + return animationState.state.cells; + } else if (playerID) { + return game.getLocalState(state, playerID); + } else { + return state.cells; + } + }); + + const animationRunning = () => getAnimationState().running; + + return function drawBoard() { + const ctx = canvas()?.getContext("2d"); + const images = tileImages(); + if (!ctx || !("cells" in state) || !images) return; + + ctx.imageSmoothingQuality = "high"; + + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + const transform = getTransform(); + const transformedCtx = new draw.TransformedCanvas2DCtx(ctx, transform); + + if (hoveredCell() && !animationRunning()) { + const [x, y] = hoveredCell()!; + const tool = selectedTool(); + let valid = true; + + if (playerID) { + // TODO: pass this logic as a parameter + if (tool.type === "placeBuilding") { + valid = game.canPlaceBuilding( + state.cells, + game.remainingResources(state.cells, state.resources, state.moves, playerID), + playerID, + x, + y, + tool.building + ); + } else if (tool.type === "attack" && tool.selected) { + valid = !!targetTiles().find(([x2, y2]) => x2 === x && y2 === y); + } else if (tool.type === "attack" && !tool.selected) { + valid = game.canAttackFrom( + state.cells, + game.remainingResources(state.cells, state.resources, state.moves, playerID), + playerID, + x, + y + ); + } + } + draw.drawCellOutline(transformedCtx, x, y, valid); + } + + for (const [x, y, cell] of game.iterateCells(cells())) { + draw.drawCell(transformedCtx, cell, x, y, images); + if (cell.active) { + draw.drawCellOutline(transformedCtx, x, y, true); + } + if (cell.target) { + draw.drawCellTarget(transformedCtx, x, y, images); + } + } + + for (const [x, y] of targetTiles()) { + draw.drawCellTarget(transformedCtx, x, y, images); + } + } +} diff --git a/src/game.ts b/src/game.ts index 11ab26a..438ac3f 100644 --- a/src/game.ts +++ b/src/game.ts @@ -7,15 +7,17 @@ export type PlaceableBuildings = { [K in keyof typeof Buildings]: typeof Buildings[K] extends { cost: number } ? K : never }[keyof typeof Buildings]; -export type Cell = { +export type Cell = ({ owner: null, - attacked?: number, } | { owner: string, building: keyof typeof Buildings, hp: number, ghost?: boolean, +}) & { attacked?: number, + active?: boolean, + target?: boolean, }; export type Move = { @@ -324,6 +326,7 @@ export function canAttackFrom( return true; } +// TODO: make attacking moves and building construction mutually exclusive export function canAttack( cells: Record, resources: number, @@ -473,7 +476,7 @@ function hasAdjacentBuilding(grid: Record, x: number, y: number, p return false; } -function areAllPlayersReady(activePlayers: Record): boolean { +export function areAllPlayersReady(activePlayers: Record): boolean { for (const player in activePlayers) { if (activePlayers[player] !== "ready") return false; } @@ -515,6 +518,73 @@ export function* iterateCells(grid: Record) { } function applyMoves(state: State) { + // Consume `applyMovesGenerator`, which mutates `state` accordingly. + for (const _ of applyMovesGenerator(state)) { + // Noop + } +} + +function* animateDamageZone(state: State, attackedSquares: [x: number, y: number][]) { + for (const [x, y] of attackedSquares) { + const cell = getCell(state.cells, x, y); + if (!cell) continue; + setCell(state.cells, x, y, { + ...cell, + target: true + }); + } + + yield state; + + for (const [x, y] of attackedSquares) { + const cell = getCell(state.cells, x, y); + if (!cell) continue; + + const newCell = {...cell}; + delete newCell["target"]; + + setCell(state.cells, x, y, newCell); + } +} + +function animateActiveCell(state: State, x: number, y: number, generator?: (() => Generator) | Generator): Generator; +function* animateActiveCell(state: State, x: number, y: number, generator?: (() => Generator) | Generator) { + let cell = getCell(state.cells, x, y); + if (cell) { + setCell( + state.cells, + x, + y, + { + ...cell, + active: true, + } + ); + } + + if (typeof generator === "function") { + yield* generator(); + } else if (generator) { + yield* generator; + } else { + yield state; + } + + cell = getCell(state.cells, x, y); + if (cell) { + const newCell = {...cell}; + delete newCell["active"]; + setCell( + state.cells, + x, + y, + newCell + ); + } +} + +// Progressively applies the moves in `state`, mutating it, and yielding whenever a move was made. +export function* applyMovesGenerator(state: State, animation?: boolean) { const players = Object.keys(state.moves); const previousCells = {...state.cells}; const moves = Object.entries(state.moves) @@ -555,6 +625,9 @@ function applyMoves(state: State) { // If two players try to build on the same tile, then nothing happens if (orders.length > 1) { + if (animation) { + yield* animateActiveCell(state, x, y); + } continue; } @@ -568,9 +641,14 @@ function applyMoves(state: State) { }); state.resources[order[0]] -= cost; + + if (animation) { + yield* animateActiveCell(state, x, y); + } } // Attacking step + let hasAttacked = false; const attackingMoves = moves .flatMap(([player, move]): [string, Move & { type: "attack" }][] => move.type === "attack" ? [[player, move]] : []); for (const [player, move] of attackingMoves) { @@ -590,24 +668,48 @@ function applyMoves(state: State) { if (!("attack" in building)) continue; const attackedSquares = building.attack.damageZone(previousCells, move.x, move.y, move.targetX, move.targetY); + for (const [x, y] of attackedSquares) { const attackedBuilding = getCell(state.cells, x, y); - if (!attackedBuilding?.owner) continue; + if (!attackedBuilding) continue; - const hp = attackedBuilding.hp - building.attack.power; - if (hp <= 0) { - setCell(state.cells, x, y, { - owner: null - }); - } else { - setCell(state.cells, x, y, { - ...attackedBuilding, - hp - }); + let attacked = building.attack.power; + if (attackedBuilding.attacked) { + attacked += attackedBuilding.attacked; } + + setCell(state.cells, x, y, { + ...attackedBuilding, + attacked + }); + } + + if (animation) { + yield* animateActiveCell(state, move.x, move.y, animateDamageZone(state, attackedSquares)); + } + hasAttacked = true; + } + + // Damage step + for (const [x, y, cell] of iterateCells(state.cells)) { + const hp = Math.max(cell.owner ? cell.hp : 0, 0) - Math.max(cell.attacked ?? 0, 0); + + if (hp <= 0) { + setCell(state.cells, x, y, { + owner: null + }); + } else if (cell.attacked) { + const newCell = {...cell} as (Cell & { owner: string }); + delete newCell["attacked"]; + newCell.hp = hp; + setCell(state.cells, x, y, newCell); } } + if (animation && hasAttacked) { + yield state; + } + // Resource gathering step for (const player of players) { state.resources[player] += extractedResources[player]!;