Add animations, clean up Game.tsx

main
Shad Amethyst 11 months ago
parent d91f2b34e3
commit fa07df8452

@ -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" />
<defs

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

@ -10,6 +10,14 @@
.board {
grid-area: board;
position: relative;
width: fit-content;
}
.turn {
position: absolute;
bottom: 0;
right: 0;
}
.players {

@ -1,17 +1,22 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
import { Client } from "boardgame.io/client";
import { Local, SocketIO } from "boardgame.io/multiplayer";
import * as game from "../game.js";
import * as draw from "./Game/draw.ts";
import { Show, createEffect, createMemo, createSignal } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import { PixelPerfectCanvas } from "@shadryx/pptk/solid";
import Toolbar, { Tool } from "./Toolbar.jsx";
import { Client } from "boardgame.io/client";
import { Local, SocketIO } from "boardgame.io/multiplayer";
import { SOCKETIO_SERVER } from "../consts.js";
import { loadTileImages } from "./tiles.js";
import clone from "../clone.js";
import classes from "./Game.module.css";
import * as game from "../game.js";
import Toolbar, { Tool } from "./Toolbar.jsx";
import Status from "./Status.tsx";
import classes from "./Game.module.css";
import * as draw from "./Game/draw.ts";
import useAnimation from "./Game/useAnimation.ts";
import useDrawBoard from "./Game/useDrawBoard.ts";
import useBoardMouse from "./Game/useBoardMouse.ts";
export type GameProps = ({
playerID: string,
solo: true,
@ -30,6 +35,10 @@ export type GameProps = ({
};
export default function Game(props: GameProps) {
const [canvas, setCanvas] = createSignal<HTMLCanvasElement>();
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<string>("");
const [stage, setStage] = createSignal<string>();
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<Tool>({
type: "placeBuilding",
building: "road"
});
const [tileImages] = createResource(loadTileImages);
const [canvas, setCanvas] = createSignal<HTMLCanvasElement>();
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 <div class={classes.game}>
<Show when={props.solo === true ? soloTurn() === props.playerID : true}>
<div class={classes.board}>
@ -262,6 +195,9 @@ export default function Game(props: GameProps) {
height: "min(900px, 50vw, 80vh - 13em)",
}}
/>
<div class={classes.turn}>
Turn {turn()}
</div>
</div>
<Show when={props.matchID}>
{(matchID) => <div class={classes.players}><Status gameName={game.AcrossTheHex.name!} matchID={matchID()} /></div>}
@ -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"}
/></div>;
}}

@ -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<T> = {
running: true,
state: T
} | {
running: false,
state: undefined
};
export function useAnimationQueue<T>(
delay: number = 1000,
callback: (animationState: AnimationState<T>) => void,
) {
const generatorQueue: Generator<T, T | undefined, unknown>[] = [];
const [running, setRunning] = createSignal(false);
let interval: ReturnType<typeof setInterval> | 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<T, T | undefined, unknown>) {
generatorQueue.push(generator);
if (!interval) {
interval = setInterval(scheduler, delay);
setRunning(true);
}
},
running,
};
}
export default function useAnimation(
state: State,
delay: number,
) {
const [temporaryState, setTemporaryState] = createSignal<State>();
const {
start,
running,
} = useAnimationQueue<State>(delay, (animationState) => {
setTemporaryState(clone(animationState.state));
});
return {
start() {
const stateClone = clone(unwrap(state));
const generator: Generator<State, State, unknown> = applyMovesGenerator(stateClone, true);
setTemporaryState(clone(stateClone));
start(generator);
},
running,
state: temporaryState,
get(): AnimationState<State> {
return {
running: running(),
state: temporaryState()
} as AnimationState<State>;
},
}
}

@ -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<HTMLCanvasElement | undefined>,
state: game.State,
getTransform: Accessor<draw.Transform>,
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);
});
});
}

@ -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<HTMLCanvasElement | undefined>,
state: game.State,
getTransform: Accessor<draw.Transform>,
hoveredCell: Accessor<[x: number, y: number] | null>,
selectedTool: Accessor<Tool>,
getAnimationState: Accessor<AnimationState<game.State>>,
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);
}
}
}

@ -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<string, Cell>,
resources: number,
@ -473,7 +476,7 @@ function hasAdjacentBuilding(grid: Record<string, Cell>, x: number, y: number, p
return false;
}
function areAllPlayersReady(activePlayers: Record<string, string>): boolean {
export function areAllPlayersReady(activePlayers: Record<string, string>): boolean {
for (const player in activePlayers) {
if (activePlayers[player] !== "ready") return false;
}
@ -515,6 +518,73 @@ export function* iterateCells(grid: Record<string, Cell>) {
}
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<T, U>(state: State, x: number, y: number, generator?: (() => Generator<T, U>) | Generator<T, U>): Generator<T, U>;
function* animateActiveCell<T, U>(state: State, x: number, y: number, generator?: (() => Generator<T, U>) | Generator<T, U>) {
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]!;

Loading…
Cancel
Save