|
|
@ -1,33 +1,56 @@
|
|
|
|
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
|
|
|
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
|
|
|
import { Client } from "boardgame.io/client";
|
|
|
|
import { Client } from "boardgame.io/client";
|
|
|
|
import { Local } from "boardgame.io/multiplayer";
|
|
|
|
import { Local, SocketIO } from "boardgame.io/multiplayer";
|
|
|
|
import { AcrossTheHex, Buildings, Cell, Moves, State, adjacentCells, canAttack, canAttackFrom, canPlaceBuilding, cellIn, getCell, getLocalState, getResourceGain, iterateCells, remainingResources } from "../game.js";
|
|
|
|
import * as game from "../game.js";
|
|
|
|
// import type { State as GIOState } from "boardgame.io";
|
|
|
|
|
|
|
|
import { createStore, reconcile } from "solid-js/store";
|
|
|
|
import { createStore, reconcile } from "solid-js/store";
|
|
|
|
import { PixelPerfectCanvas } from "@shadryx/pptk/solid";
|
|
|
|
import { PixelPerfectCanvas } from "@shadryx/pptk/solid";
|
|
|
|
import Toolbar, { Tool } from "./Toolbar.jsx";
|
|
|
|
import Toolbar, { Tool } from "./Toolbar.jsx";
|
|
|
|
import { GHOST_COLORS, PLAYER_COLORS } from "../consts.js";
|
|
|
|
import { GHOST_COLORS, PLAYER_COLORS, SOCKETIO_SERVER } from "../consts.js";
|
|
|
|
import { TileImages, loadTileImages } from "./tiles.js";
|
|
|
|
import { TileImages, loadTileImages } from "./tiles.js";
|
|
|
|
|
|
|
|
import clone from "../clone.js";
|
|
|
|
|
|
|
|
|
|
|
|
export type GameProps = {
|
|
|
|
export type GameProps = ({
|
|
|
|
playerID: string,
|
|
|
|
playerID: string,
|
|
|
|
solo?: boolean,
|
|
|
|
solo: true,
|
|
|
|
|
|
|
|
} | {
|
|
|
|
|
|
|
|
playerID: string,
|
|
|
|
|
|
|
|
solo?: false,
|
|
|
|
|
|
|
|
matchID: string,
|
|
|
|
|
|
|
|
} | {
|
|
|
|
|
|
|
|
playerID?: undefined,
|
|
|
|
|
|
|
|
solo?: false,
|
|
|
|
|
|
|
|
matchID: string,
|
|
|
|
|
|
|
|
}) & {
|
|
|
|
|
|
|
|
debug?: boolean
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default function Game(props: GameProps) {
|
|
|
|
export default function Game(props: GameProps) {
|
|
|
|
const client = Client({
|
|
|
|
const client = Client({
|
|
|
|
game: AcrossTheHex,
|
|
|
|
game: game.AcrossTheHex,
|
|
|
|
multiplayer: Local(),
|
|
|
|
multiplayer: props.solo ? Local() : SocketIO({
|
|
|
|
playerID: props.playerID
|
|
|
|
server: SOCKETIO_SERVER
|
|
|
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
debug: props.debug ?? false,
|
|
|
|
|
|
|
|
...("matchID" in props ? { matchID: props.matchID } : {}),
|
|
|
|
|
|
|
|
...(props.playerID ? { playerID: props.playerID } : {})
|
|
|
|
});
|
|
|
|
});
|
|
|
|
const [state, setState] = createStore<State>(client.getState()?.G ?? {
|
|
|
|
const [state, setState] = createStore<game.State>(clone(client.getState()?.G) ?? {
|
|
|
|
resources: {},
|
|
|
|
resources: {},
|
|
|
|
cells: {},
|
|
|
|
cells: {},
|
|
|
|
moves: {}
|
|
|
|
moves: {}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
const [soloTurn, setSoloTurn] = createSignal<string>("");
|
|
|
|
const [soloTurn, setSoloTurn] = createSignal<string>("");
|
|
|
|
const [stage, setStage] = createSignal<string>();
|
|
|
|
const [stage, setStage] = createSignal<string>();
|
|
|
|
const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {});
|
|
|
|
|
|
|
|
|
|
|
|
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 [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null);
|
|
|
|
const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null);
|
|
|
|
const [selectedTool, setSelectedTool] = createSignal<Tool>({
|
|
|
|
const [selectedTool, setSelectedTool] = createSignal<Tool>({
|
|
|
|
type: "placeBuilding",
|
|
|
|
type: "placeBuilding",
|
|
|
@ -42,18 +65,18 @@ export default function Game(props: GameProps) {
|
|
|
|
|
|
|
|
|
|
|
|
const targetTiles = createMemo(() => {
|
|
|
|
const targetTiles = createMemo(() => {
|
|
|
|
const tool = selectedTool();
|
|
|
|
const tool = selectedTool();
|
|
|
|
if (tool.type !== "attack" || !tool.selected) return [];
|
|
|
|
if (tool.type !== "attack" || !tool.selected || !props.playerID) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const building = getCell(state.cells, tool.selected.x, tool.selected.y);
|
|
|
|
const building = game.getCell(state.cells, tool.selected.x, tool.selected.y);
|
|
|
|
if (building?.owner !== props.playerID) return [];
|
|
|
|
if (building?.owner !== props.playerID) return [];
|
|
|
|
const buildingRules = Buildings[building.building];
|
|
|
|
const buildingRules = game.Buildings[building.building];
|
|
|
|
if (!("attack" in buildingRules)) return [];
|
|
|
|
if (!("attack" in buildingRules)) return [];
|
|
|
|
|
|
|
|
|
|
|
|
return buildingRules.attack.targets(state.cells, tool.selected.x, tool.selected.y)
|
|
|
|
return buildingRules.attack.targets(state.cells, tool.selected.x, tool.selected.y)
|
|
|
|
.filter(([x, y]) => cellIn(state.cells, x, y));
|
|
|
|
.filter(([x, y]) => game.cellIn(state.cells, x, y));
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const moves = () => client.moves as Moves;
|
|
|
|
const moves = () => client.moves as game.Moves;
|
|
|
|
|
|
|
|
|
|
|
|
const getTransform = createMemo(() => {
|
|
|
|
const getTransform = createMemo(() => {
|
|
|
|
if (!("cells" in state)) return {
|
|
|
|
if (!("cells" in state)) return {
|
|
|
@ -89,13 +112,15 @@ export default function Game(props: GameProps) {
|
|
|
|
const images = tileImages();
|
|
|
|
const images = tileImages();
|
|
|
|
if (!ctx || !("cells" in state) || !images) return;
|
|
|
|
if (!ctx || !("cells" in state) || !images) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ctx.imageSmoothingQuality = "high";
|
|
|
|
|
|
|
|
|
|
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
|
|
const transform = getTransform();
|
|
|
|
const transform = getTransform();
|
|
|
|
|
|
|
|
|
|
|
|
ctx.setTransform(transform.scale, 0, 0, transform.scale, transform.sx, transform.sy);
|
|
|
|
ctx.setTransform(transform.scale, 0, 0, transform.scale, transform.sx, transform.sy);
|
|
|
|
|
|
|
|
|
|
|
|
for (const [x, y, cell] of iterateCells(cells())) {
|
|
|
|
for (const [x, y, cell] of game.iterateCells(cells())) {
|
|
|
|
drawCell(ctx, cell, x, y, images);
|
|
|
|
drawCell(ctx, cell, x, y, images);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -104,10 +129,11 @@ export default function Game(props: GameProps) {
|
|
|
|
const tool = selectedTool();
|
|
|
|
const tool = selectedTool();
|
|
|
|
let valid = true;
|
|
|
|
let valid = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (props.playerID) {
|
|
|
|
if (tool.type === "placeBuilding") {
|
|
|
|
if (tool.type === "placeBuilding") {
|
|
|
|
valid = canPlaceBuilding(
|
|
|
|
valid = game.canPlaceBuilding(
|
|
|
|
state.cells,
|
|
|
|
state.cells,
|
|
|
|
remainingResources(state.resources, state.moves, props.playerID),
|
|
|
|
game.remainingResources(state.cells, state.resources, state.moves, props.playerID),
|
|
|
|
props.playerID,
|
|
|
|
props.playerID,
|
|
|
|
x,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
y,
|
|
|
@ -116,14 +142,15 @@ export default function Game(props: GameProps) {
|
|
|
|
} else if (tool.type === "attack" && tool.selected) {
|
|
|
|
} else if (tool.type === "attack" && tool.selected) {
|
|
|
|
valid = !!targetTiles().find(([x2, y2]) => x2 === x && y2 === y);
|
|
|
|
valid = !!targetTiles().find(([x2, y2]) => x2 === x && y2 === y);
|
|
|
|
} else if (tool.type === "attack" && !tool.selected) {
|
|
|
|
} else if (tool.type === "attack" && !tool.selected) {
|
|
|
|
valid = canAttackFrom(
|
|
|
|
valid = game.canAttackFrom(
|
|
|
|
state.cells,
|
|
|
|
state.cells,
|
|
|
|
remainingResources(state.resources, state.moves, props.playerID),
|
|
|
|
game.remainingResources(state.cells, state.resources, state.moves, props.playerID),
|
|
|
|
props.playerID,
|
|
|
|
props.playerID,
|
|
|
|
x,
|
|
|
|
x,
|
|
|
|
y
|
|
|
|
y
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
drawCellOutline(ctx, x, y, valid);
|
|
|
|
drawCellOutline(ctx, x, y, valid);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -133,15 +160,17 @@ export default function Game(props: GameProps) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clickTile(x: number, y: number) {
|
|
|
|
function clickTile(x: number, y: number) {
|
|
|
|
|
|
|
|
if (!props.playerID) return;
|
|
|
|
|
|
|
|
|
|
|
|
const tool = selectedTool();
|
|
|
|
const tool = selectedTool();
|
|
|
|
|
|
|
|
|
|
|
|
if (tool.type === "placeBuilding") {
|
|
|
|
if (tool.type === "placeBuilding") {
|
|
|
|
moves().placeBuilding?.(x, y, tool.building);
|
|
|
|
moves().placeBuilding?.(x, y, tool.building);
|
|
|
|
} else if (tool.type === "attack") {
|
|
|
|
} else if (tool.type === "attack") {
|
|
|
|
if (tool.selected) {
|
|
|
|
if (tool.selected) {
|
|
|
|
if (canAttack(
|
|
|
|
if (game.canAttack(
|
|
|
|
state.cells,
|
|
|
|
state.cells,
|
|
|
|
remainingResources(state.resources, state.moves, props.playerID),
|
|
|
|
game.remainingResources(state.cells, state.resources, state.moves, props.playerID),
|
|
|
|
props.playerID,
|
|
|
|
props.playerID,
|
|
|
|
tool.selected.x,
|
|
|
|
tool.selected.x,
|
|
|
|
tool.selected.y,
|
|
|
|
tool.selected.y,
|
|
|
@ -165,17 +194,21 @@ export default function Game(props: GameProps) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
client.start();
|
|
|
|
client.start();
|
|
|
|
client.subscribe((state) => {
|
|
|
|
client.subscribe((state) => {
|
|
|
|
if (state) {
|
|
|
|
if (state) {
|
|
|
|
setState(reconcile(state.G));
|
|
|
|
// For some ungodly reason, I need to clone the state, because otherwise the frozenness sporadically gets
|
|
|
|
setStage(state.ctx.activePlayers![props.playerID]);
|
|
|
|
// 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
|
|
|
|
|
|
|
|
setState(reconcile(clone(state.G)));
|
|
|
|
|
|
|
|
if (props.playerID) {
|
|
|
|
|
|
|
|
setStage(state.ctx.activePlayers?.[props.playerID] ?? "connecting");
|
|
|
|
|
|
|
|
}
|
|
|
|
if (props.solo) {
|
|
|
|
if (props.solo) {
|
|
|
|
const activePlayers = Object.entries(state.ctx.activePlayers ?? {})
|
|
|
|
const activePlayers = Object.entries(state.ctx.activePlayers ?? {})
|
|
|
|
.flatMap(([player, state]) => state === "prepare" ? [player] : [])
|
|
|
|
.flatMap(([player, state]) => state === "prepare" ? [player] : [])
|
|
|
|
.sort((a, b) => +b - +a);
|
|
|
|
.sort((a, b) => +b - +a);
|
|
|
|
setSoloTurn(activePlayers[activePlayers.length - 1] ?? "");
|
|
|
|
setSoloTurn(activePlayers[activePlayers.length - 1] ?? "0");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
@ -197,7 +230,7 @@ export default function Game(props: GameProps) {
|
|
|
|
const y = ((event.clientY - bounds.top) / window.devicePixelRatio - transform.sy) / transform.scale;
|
|
|
|
const y = ((event.clientY - bounds.top) / window.devicePixelRatio - transform.sy) / transform.scale;
|
|
|
|
|
|
|
|
|
|
|
|
const [hexX, hexY] = inverseHexPosition(x, y);
|
|
|
|
const [hexX, hexY] = inverseHexPosition(x, y);
|
|
|
|
if ("cells" in state && cellIn(state.cells, hexX, hexY)) {
|
|
|
|
if ("cells" in state && game.cellIn(state.cells, hexX, hexY)) {
|
|
|
|
return [hexX, hexY];
|
|
|
|
return [hexX, hexY];
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
return null;
|
|
|
@ -235,19 +268,23 @@ export default function Game(props: GameProps) {
|
|
|
|
draw();
|
|
|
|
draw();
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
width: "400px",
|
|
|
|
width: "600px",
|
|
|
|
height: "400px",
|
|
|
|
height: "600px",
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
<Toolbar
|
|
|
|
<Show when={props.playerID}>
|
|
|
|
playerID={props.playerID}
|
|
|
|
{(playerID) => {
|
|
|
|
|
|
|
|
return <Toolbar
|
|
|
|
|
|
|
|
playerID={playerID()}
|
|
|
|
selectedTool={selectedTool}
|
|
|
|
selectedTool={selectedTool}
|
|
|
|
setSelectedTool={setSelectedTool}
|
|
|
|
setSelectedTool={setSelectedTool}
|
|
|
|
resources={() => "resources" in state ? remainingResources(state.resources, state.moves, props.playerID) : 0}
|
|
|
|
resources={() => "resources" in state ? game.remainingResources(state.cells, state.resources, state.moves, playerID()) : 0}
|
|
|
|
resourceGain={() => "cells" in state ? getResourceGain(state.cells, props.playerID) : 0}
|
|
|
|
resourceGain={() => "cells" in state ? game.getResourceGain(state.cells, playerID()) : 0}
|
|
|
|
setReady={() => moves().setReady?.()}
|
|
|
|
setReady={() => moves().setReady?.()}
|
|
|
|
ready={() => stage() === "ready"}
|
|
|
|
ready={() => stage() === "ready"}
|
|
|
|
/>
|
|
|
|
/>;
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
</Show>
|
|
|
|
</Show>
|
|
|
|
</Show>
|
|
|
|
</div>;
|
|
|
|
</div>;
|
|
|
|
}
|
|
|
|
}
|
|
|
@ -288,7 +325,7 @@ function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number,
|
|
|
|
|
|
|
|
|
|
|
|
let closest = [guessHexX, guessHexY, distance(guessHexX, guessHexY)] as const;
|
|
|
|
let closest = [guessHexX, guessHexY, distance(guessHexX, guessHexY)] as const;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [neighborX, neighborY] of adjacentCells(guessHexX, guessHexY)) {
|
|
|
|
for (const [neighborX, neighborY] of game.adjacentCells(guessHexX, guessHexY)) {
|
|
|
|
const dist = distance(neighborX, neighborY);
|
|
|
|
const dist = distance(neighborX, neighborY);
|
|
|
|
if (dist < closest[2]) {
|
|
|
|
if (dist < closest[2]) {
|
|
|
|
closest = [neighborX, neighborY, dist];
|
|
|
|
closest = [neighborX, neighborY, dist];
|
|
|
@ -298,7 +335,7 @@ function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number,
|
|
|
|
return [closest[0], closest[1]];
|
|
|
|
return [closest[0], closest[1]];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getBounds(grid: Record<string, Cell>) {
|
|
|
|
function getBounds(grid: Record<string, game.Cell>) {
|
|
|
|
let res = {
|
|
|
|
let res = {
|
|
|
|
minX: Infinity,
|
|
|
|
minX: Infinity,
|
|
|
|
maxX: -Infinity,
|
|
|
|
maxX: -Infinity,
|
|
|
@ -306,7 +343,7 @@ function getBounds(grid: Record<string, Cell>) {
|
|
|
|
maxY: -Infinity
|
|
|
|
maxY: -Infinity
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
for (const [x, y] of iterateCells(grid)) {
|
|
|
|
for (const [x, y] of game.iterateCells(grid)) {
|
|
|
|
const [x2, y2] = getHexPosition(x, y);
|
|
|
|
const [x2, y2] = getHexPosition(x, y);
|
|
|
|
res.minX = Math.min(res.minX, x2 + HEX_BOUNDS.left);
|
|
|
|
res.minX = Math.min(res.minX, x2 + HEX_BOUNDS.left);
|
|
|
|
res.maxX = Math.max(res.maxX, x2 + HEX_BOUNDS.right);
|
|
|
|
res.maxX = Math.max(res.maxX, x2 + HEX_BOUNDS.right);
|
|
|
@ -353,13 +390,13 @@ function drawCellInfoBubble(ctx: CanvasRenderingContext2D, x: number, y: number,
|
|
|
|
ctx.fill();
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = "white";
|
|
|
|
ctx.fillStyle = "white";
|
|
|
|
ctx.font = `${RADIUS}px sans-serif`;
|
|
|
|
ctx.font = `${RADIUS}px Arial`;
|
|
|
|
ctx.textAlign = "center";
|
|
|
|
ctx.textAlign = "center";
|
|
|
|
ctx.textBaseline = "middle";
|
|
|
|
ctx.textBaseline = "middle";
|
|
|
|
ctx.fillText(value, dotX, dotY - 0.2);
|
|
|
|
ctx.fillText(value, dotX, dotY);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number, tileImages: TileImages) {
|
|
|
|
function drawCell(ctx: CanvasRenderingContext2D, cell: game.Cell, x: number, y: number, tileImages: TileImages) {
|
|
|
|
const [x2, y2] = getHexPosition(x, y);
|
|
|
|
const [x2, y2] = getHexPosition(x, y);
|
|
|
|
|
|
|
|
|
|
|
|
let line_offset = (LINE_WIDTH + SPACING) / 2;
|
|
|
|
let line_offset = (LINE_WIDTH + SPACING) / 2;
|
|
|
@ -392,7 +429,7 @@ function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: numbe
|
|
|
|
drawCellImage(ctx, x, y, tileImages[cell.building]);
|
|
|
|
drawCellImage(ctx, x, y, tileImages[cell.building]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cell.owner && !cell.ghost && (cell.hp !== Buildings[cell.building].hp || true)) {
|
|
|
|
if (cell.owner && !cell.ghost && (cell.hp !== game.Buildings[cell.building].hp || true)) {
|
|
|
|
drawCellInfoBubble(
|
|
|
|
drawCellInfoBubble(
|
|
|
|
ctx,
|
|
|
|
ctx,
|
|
|
|
x,
|
|
|
|
x,
|
|
|
|