Toolbar, implement selecting buildings to place

main
Shad Amethyst 1 year ago
parent fce9712698
commit 59c6fed541

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="173.2"
height="200"
viewBox="0 0 1.732 2"
version="1.1"
id="svg7723"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tile-crown.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7725"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="2.8284271"
inkscape:cx="61.695066"
inkscape:cy="70.180348"
inkscape:window-width="1920"
inkscape:window-height="986"
inkscape:window-x="0"
inkscape:window-y="72"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs7720" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="display:none;fill:#707070;fill-opacity:1;stroke:#4050f0;stroke-width:0.132184;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path10510"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="2.6478419"
sodipodi:cy="0.71499872"
sodipodi:r1="0.60318714"
sodipodi:r2="0.5223754"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 3.1702173,1.0165923 2.6478419,1.3181859 2.1254665,1.0165923 l 0,-0.60318715 0.5223754,-0.30159357 0.5223754,0.30159357 z"
transform="matrix(1.471662,0,0,1.471662,-3.0307033,-0.05223681)"
inkscape:label="bg" />
<path
style="fill:#fffe69;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0.52754975,1.1970191 V 0.79586279 L 0.70586365,0.96870513 0.86898865,0.81037987 1.0360291,0.9674099 1.2127287,0.81766244 V 1.1912112 Z"
id="path25114"
sodipodi:nodetypes="cccccccc"
inkscape:label="crown" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="173.2"
height="200"
viewBox="0 0 1.732 2"
version="1.1"
id="svg7723"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tile-factory.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7725"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="2.8284271"
inkscape:cx="129.22376"
inkscape:cy="78.312076"
inkscape:window-width="1920"
inkscape:window-height="1004"
inkscape:window-x="0"
inkscape:window-y="54"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs7720" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="display:none;fill:#707070;fill-opacity:1;stroke:#4050f0;stroke-width:0.132184;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path10510"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="2.6478419"
sodipodi:cy="0.71499872"
sodipodi:r1="0.60318714"
sodipodi:r2="0.5223754"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 3.1702173,1.0165923 2.6478419,1.3181859 2.1254665,1.0165923 l 0,-0.60318715 0.5223754,-0.30159357 0.5223754,0.30159357 z"
transform="matrix(1.471662,0,0,1.471662,-3.0307033,-0.05223681)"
inkscape:label="bg" />
<path
style="fill:#3d3d3d;fill-opacity:1;stroke:none;stroke-width:0.0138816px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0.39608133,0.73440037 H 0.5970082 V 0.87661338 L 0.77228237,0.77541877 h 0.071046 l 10e-9,0.10119461 0.17527412,-0.1011946 h 0.071046 v 0.1011946 l 0.1752743,-0.10119463 0.071046,0 V 1.2655989 H 0.39608133 Z"
id="path27946"
sodipodi:nodetypes="cccccccccccccc" />
<path
style="fill:#363636;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="m 0.59700819,0.87661337 h -0.071046 V 0.73440036 h 0.071046 z"
id="path30855" />
<path
style="fill:#363636;fill-opacity:1;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 0.84332837,0.87661337 -0.071046,-10e-9 V 0.77541877 h 0.071046 z"
id="path30859"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#363636;fill-opacity:1;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 1.0896485,0.87661337 -0.071046,-10e-9 V 0.77541878 h 0.071046 z"
id="path30861"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#363636;fill-opacity:1;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 1.3359688,0.77541875 0,0.49018015 H 1.2649228 V 0.77541875 Z"
id="path30863"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#525252;fill-opacity:1;stroke:none;stroke-width:0.0190803px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 0.41494283,0.75550686 h 0.0498477 v 0.1 h -0.05 z"
id="path33779"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="173.2"
height="200"
viewBox="0 0 1.732 2"
version="1.1"
id="svg7723"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tile-pawn.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7725"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="2.8284271"
inkscape:cx="54.623999"
inkscape:cy="121.44559"
inkscape:window-width="1920"
inkscape:window-height="1022"
inkscape:window-x="0"
inkscape:window-y="36"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs7720" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="display:none;fill:#707070;fill-opacity:1;stroke:#4050f0;stroke-width:0.132184;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path10510"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="2.6478419"
sodipodi:cy="0.71499872"
sodipodi:r1="0.60318714"
sodipodi:r2="0.5223754"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 3.1702173,1.0165923 2.6478419,1.3181859 2.1254665,1.0165923 l 0,-0.60318715 0.5223754,-0.30159357 0.5223754,0.30159357 z"
transform="matrix(1.471662,0,0,1.471662,-3.0307033,-0.05223681)"
inkscape:label="bg" />
<path
style="fill:#3d3d3d;fill-opacity:1;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0.48933913,0.61425591 0.86738879,0.71555401 1.2133134,0.62286381 V 1.1584438 L 0.86935783,1.5023993 0.48929247,1.1227182 Z"
id="path26978"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#363636;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="M 0.86738879,0.71555401 0.86935783,1.5023993 1.2133134,1.1584438 V 0.62286381 Z"
id="path26980" />
<path
style="fill:none;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 0.67836396,0.66490496 -0.18905953,0.32746071 7.14e-6,-0.07774 0.15018855,-0.26013421 z"
id="path26984" />
<path
style="fill:#525252;fill-opacity:1;stroke:none;stroke-width:0.01px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 0.52308404,0.66314323 -1.5e-5,0.16340521 0.18903983,-0.11275616 z"
id="path26986" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="173.2"
height="200"
viewBox="0 0 1.732 2"
version="1.1"
id="svg7723"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tile-player0.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7725"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="4"
inkscape:cx="60.125"
inkscape:cy="93.625"
inkscape:window-width="1920"
inkscape:window-height="1004"
inkscape:window-x="0"
inkscape:window-y="54"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs7720" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#707070;fill-opacity:1;stroke:#f06040;stroke-width:0.132184;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path10510"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="2.6478419"
sodipodi:cy="0.71499872"
sodipodi:r1="0.60318714"
sodipodi:r2="0.5223754"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 3.1702173,1.0165923 2.6478419,1.3181859 2.1254665,1.0165923 l 0,-0.60318715 0.5223754,-0.30159357 0.5223754,0.30159357 z"
transform="matrix(1.4716625,0,0,1.4716625,-3.030706,-0.05223695)"
inkscape:label="bg" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@ -19,6 +19,7 @@ export type Props = {
min-height: 100svh; min-height: 100svh;
overflow-x: hidden; overflow-x: hidden;
background: #202020; background: #202020;
color: white;
} }
</style> </style>
</head> </head>

@ -1,27 +1,37 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { 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 } from "boardgame.io/multiplayer";
import { AcrossTheHex, Cell, State, adjacentCells, cellIn, getLocalState, iterateCells } from "../game.js"; import { AcrossTheHex, Cell, State, adjacentCells, canPlaceBuilding, cellIn, getLocalState, getResourceGain, iterateCells, remainingResources } from "../game.js";
// import type { State as GIOState } from "boardgame.io"; // 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 { PLAYER_COLORS } from "../consts.js";
import { TileImages, loadTileImages } from "./tiles.js";
export type GameProps = { export type GameProps = {
playerID: string playerID: string
}; };
const PLAYER_COLORS = ["#f06040", "#4050f0"];
export default function Game(props: GameProps) { export default function Game(props: GameProps) {
const client = Client({ const client = Client({
game: AcrossTheHex, game: AcrossTheHex,
multiplayer: Local(), multiplayer: Local(),
playerID: props.playerID playerID: props.playerID
}); });
const [state, setState] = createStore<State | {}>(client.getState() ?? {}); const [state, setState] = createStore<State>(client.getState()?.G ?? {
resources: {},
cells: {},
moves: {}
});
const [stage, setStage] = createSignal<string>(); const [stage, setStage] = createSignal<string>();
const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {}); const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {});
const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null); 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 [canvas, setCanvas] = createSignal<HTMLCanvasElement>();
@ -59,7 +69,9 @@ export default function Game(props: GameProps) {
function draw() { function draw() {
const ctx = canvas()?.getContext("2d"); const ctx = canvas()?.getContext("2d");
if (!ctx || !("cells" in state)) return; const images = tileImages();
if (!ctx || !("cells" in state) || !images) return;
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();
@ -67,17 +79,34 @@ export default function Game(props: GameProps) {
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 iterateCells(cells())) {
drawCell(ctx, cell, x, y); drawCell(ctx, cell, x, y, images);
} }
if (hoveredCell()) { if (hoveredCell()) {
const [x, y] = hoveredCell()!; const [x, y] = hoveredCell()!;
drawCellOutline(ctx, x, y); const tool = selectedTool();
let valid = true;
if (tool.type === "placeBuilding") {
valid = canPlaceBuilding(
state.cells,
remainingResources(state.resources, state.moves, props.playerID),
props.playerID,
x,
y,
tool.building
);
}
drawCellOutline(ctx, x, y, valid);
} }
} }
function clickTile(x: number, y: number) { function clickTile(x: number, y: number) {
client.moves.placeBuilding?.(x, y, "pawn"); const tool = selectedTool();
if (tool.type === "placeBuilding") {
client.moves.placeBuilding?.(x, y, tool.building);
}
} }
client.start(); client.start();
@ -146,7 +175,16 @@ export default function Game(props: GameProps) {
height: "400px", height: "400px",
}} }}
/> />
<button onClick={() => client.moves.setReady?.()}>{stage() === "ready" ? "Waiting for opponent" : "Done"}</button> <div>Resources</div>
<Toolbar
playerID={props.playerID}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
resources={() => "resources" in state ? remainingResources(state.resources, state.moves, props.playerID) : 0}
resourceGain={() => "cells" in state ? getResourceGain(state.cells, props.playerID) : 0}
setReady={() => client.moves.setReady?.()}
ready={() => stage() === "ready"}
/>
</div>; </div>;
} }
@ -228,7 +266,7 @@ function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_o
ctx.closePath(); ctx.closePath();
} }
function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number) { function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number, tileImages: TileImages) {
const [x2, y2] = getHexPosition(x, y); const [x2, y2] = getHexPosition(x, y);
const LINE_WIDTH = 0.2; const LINE_WIDTH = 0.2;
const SPACING = 0.2; const SPACING = 0.2;
@ -251,13 +289,24 @@ function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: numbe
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
} }
if (cell.owner && cell.building !== "road") {
const img = tileImages[cell.building];
ctx.drawImage(
img,
x2 + HEX_BOUNDS.left,
y2 + HEX_BOUNDS.top,
HEX_BOUNDS.right - HEX_BOUNDS.left,
HEX_BOUNDS.bottom - HEX_BOUNDS.top,
);
}
} }
function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number) { function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number, valid: boolean) {
const [x2, y2] = getHexPosition(x, y); const [x2, y2] = getHexPosition(x, y);
ctx.lineWidth = 0.05; ctx.lineWidth = 0.05;
ctx.strokeStyle = "white"; ctx.strokeStyle = valid ? "white" : "#d08080";
drawHexagon(ctx, x2, y2, 0.05); drawHexagon(ctx, x2, y2, (0.2 - 0.05) / 2);
ctx.stroke(); ctx.stroke();
} }

@ -0,0 +1,61 @@
.toolbar {
padding-inline: 2em;
}
.toolbar > ul {
list-style: none;
padding-left: 0;
display: flex;
justify-content: flex-start;
/* display: grid;
grid-template-columns: 1fr 1fr 1fr;
max-width: calc(3 * 100px); */
gap: 1em;
width: 100%;
}
.tool {
width: 100px;
height: 100px;
opacity: 0.6;
cursor: pointer;
position: relative;
}
.tool:focus {
outline: none;
}
.tool:not(.selected):hover, .tool:not(.selected):focus {
opacity: 0.8;
}
.tool.selected {
opacity: 1;
}
.tool .background {
position: absolute;
inset: 0;
}
.tool .background > svg, .tool .only-background > svg {
width: 100%;
height: 100%;
}
.tool.selected:focus .background, .tool.selected:focus .only-background {
filter: drop-shadow(0px 0px 4px white);
}
.tool .icon, .tool .only-background {
position: relative;
user-select: none;
pointer-events: none;
width: 100%;
height: 100%;
}
.description {
}

@ -0,0 +1,175 @@
import { PLAYER_COLORS } from "../consts.js";
import classes from "./Toolbar.module.css";
import PlayerTile from "../tile-player-any.svg?raw";
import { Buildings } from "../game.js";
import { For, Match, Switch } from "solid-js";
const WIDTH = 173.2;
const HEIGHT = 200;
export type Tool = {
type: "placeBuilding",
building: keyof typeof Buildings,
};
export type ToolbarProps = {
playerID: string,
selectedTool: () => Tool,
setSelectedTool: (tool: Tool) => void,
resources: () => number,
resourceGain: () => number,
setReady: (ready: boolean) => void,
ready: () => boolean,
};
export default function Toolbar(props: ToolbarProps) {
function isPlacingBuilding(building: keyof typeof Buildings) {
const tool = props.selectedTool();
if (tool.type !== "placeBuilding") return false;
return tool.building === building;
}
function selectBuilding(building: keyof typeof Buildings) {
props.setSelectedTool({
type: "placeBuilding",
building,
});
}
return <nav class={classes.toolbar}>
<ul>
<ToolbarItem
playerID={props.playerID}
type="road"
selected={() => isPlacingBuilding("road")}
onSelect={() => selectBuilding("road")}
/>
<ToolbarItem
playerID={props.playerID}
type="pawn"
selected={() => isPlacingBuilding("pawn")}
onSelect={() => selectBuilding("pawn")}
/>
<ToolbarItem
playerID={props.playerID}
type="factory"
selected={() => isPlacingBuilding("factory")}
onSelect={() => selectBuilding("factory")}
/>
<li>
Resources: {props.resources()} (+{props.resourceGain()})
<br />
<button onClick={() => props.setReady(!props.ready())}>
{props.ready() ? "Waiting..." : "Ready"}
</button>
</li>
</ul>
<Description selectedTool={props.selectedTool} />
</nav>;
}
function ToolbarItem(props: {
playerID: string,
type: keyof typeof Buildings,
selected: () => boolean,
onSelect: () => void,
}) {
const color = PLAYER_COLORS[+props.playerID % PLAYER_COLORS.length];
return <li
class={[classes.tool, props.selected() ? classes.selected : ""].filter(Boolean).join(" ")}
tabindex={0}
onClick={props.onSelect}
onKeyDown={(event) => {
if (event.key === "Enter") props.onSelect();
}}
role="button"
>
<div
innerHTML={PlayerTile}
class={props.type !== "road" ? classes.background : classes["only-background"]}
style={{
"--player-color": color
}}
></div>
{
props.type !== "road" &&
<img class={classes.icon} src={`/tile-${props.type}.svg`} width={WIDTH} height={HEIGHT} />
}
</li>;
}
const TILE_DESCRIPTIONS = {
road: "The most basic tile, can be placed on any empty tile adjacent to one of your tiles. Allows buildings to be constructed on it.",
base: "Your HQ, if you lose it, you lose the game!",
factory: "Gives a steady income of resources, but will explode the adjacent tiles if destroyed.",
pawn: "Your first line of defense: has good HP and can attack a single adjacent tile to prevent enemy expansion to the border of your territory."
} satisfies Record<keyof typeof Buildings, string>;
const TILE_NAMES = {
road: "Road",
base: "HQ",
factory: "Factory",
pawn: "Defender"
} satisfies Record<keyof typeof Buildings, string>;
type KeyOf<T> = T extends object ? keyof T : never;
type TryIndex<T, K extends string> = T extends Record<K, unknown> ? T[K] : never;
type Stats = {
[K in KeyOf<typeof Buildings[keyof typeof Buildings]>]:
(value: TryIndex<typeof Buildings[keyof typeof Buildings], K>) => string
}
const STATS = {
cost: (cost: number) => `Cost: ${cost}`,
gain: (gain: number) => `Resource gain: ${gain}/turn`,
placedOn: (tiles: readonly (keyof typeof Buildings)[]) => {
const tileNames = tiles.map(tile => TILE_NAMES[tile]);
if (tileNames.length <= 2) {
return `Must be placed on ${tileNames.join(" or ")}`;
} else {
return `Must be placed on ${tileNames.slice(0, -1).join(", ")} or ${tileNames[tiles.length - 1]}`;
}
},
} satisfies Stats;
function Description(props: {
selectedTool: () => Tool
}) {
const stats = (building: keyof typeof Buildings) => {
const keys = Object.keys(STATS) as (keyof typeof STATS)[];
const buildingRules = Buildings[building];
return keys.flatMap((key) => {
if (key in buildingRules) {
// SAFETY: can be trivially proven by applying decidability on `key`.
return [STATS[key]((buildingRules as Record<string, any>)[key])]
} else {
return [];
}
})
};
return <div class={classes.description}>
<Switch fallback={<i>No tool selected</i>}>
<Match when={props.selectedTool().type === "placeBuilding"}>
{(_) => {
const building = () => (props.selectedTool() as (Tool & { type: "placeBuilding" })).building;
return (<>
<h3>{TILE_NAMES[building()]}</h3>
<p>{TILE_DESCRIPTIONS[building()]}</p>
<ul class={classes.stats}>
<For each={stats(building())}>
{(item, index) => {
return <li data-index={index()}>{item}</li>;
}}
</For>
</ul>
</>);
}}
</Match>
</Switch>
</div>;
}

@ -0,0 +1,27 @@
import { BASE_URL } from "../consts.js";
import { Buildings } from "../game.js";
export type NonRoads = Exclude<keyof typeof Buildings, "road">;
export type TileImages = Record<NonRoads, HTMLImageElement>;
export async function loadTileImages(): Promise<TileImages> {
const tiles = await Promise.all(
(Object.keys(Buildings) as (keyof typeof Buildings)[])
.flatMap((building): Promise<[NonRoads, HTMLImageElement]>[] => {
if (building === "road") return [];
return [new Promise((resolve, reject) => {
const svg = document.createElement("img");
svg.src = BASE_URL + `./tile-${building}.svg`;
svg.addEventListener("load", () => {
resolve([building, svg]);
});
svg.addEventListener("error", (err) => {
console.error(`Error while loading image ${svg.src}: ${err}`);
reject(err);
});
})];
})
);
return Object.fromEntries(tiles) as TileImages;
}

@ -0,0 +1,3 @@
export const PLAYER_COLORS = ["#f06040", "#4050f0"];
export const BASE_URL = "/";

@ -28,16 +28,29 @@ export const Buildings = {
cost: Infinity, cost: Infinity,
gain: 3, gain: 3,
}, },
pawn: { road: {
cost: 1, cost: 1,
},
pawn: {
cost: 2,
placedOn: ["road"]
},
factory: {
cost: 3,
gain: 2,
placedOn: ["road"]
} }
} as const; } as const satisfies Readonly<Record<string, Readonly<{
cost: number,
gain?: number,
placedOn?: readonly string[]
}>>>;
export const AcrossTheHex: Game<State, Record<string, unknown>, { export const AcrossTheHex: Game<State, Record<string, unknown>, {
size?: number, size?: number,
initialResources?: number initialResources?: number
}> = { }> = {
setup({ ctx }, { size = 3, initialResources = 1 } = {}) { setup({ ctx }, { size = 3, initialResources = 2 } = {}) {
const cells = initGrid(size); const cells = initGrid(size);
const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString()); const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString());
@ -93,22 +106,54 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
} }
} as const; } as const;
function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: number, y: number, building: keyof typeof Buildings) { export function canPlaceBuilding(
cells: Record<string, Cell>,
resources: number,
playerID: string,
x: number,
y: number,
building: keyof typeof Buildings
): boolean {
// Cannot place a building outside the field // Cannot place a building outside the field
if (!cellIn(G.cells, x, y)) return INVALID_MOVE; if (!cellIn(cells, x, y)) return false;
// Must place a building next to an already-established friendly building // Must place a building next to an already-established friendly building
if (!hasAdjacentBuilding(G.cells, x, y, playerID)) return INVALID_MOVE; if (!hasAdjacentBuilding(cells, x, y, playerID)) return false;
// Cannot place a building on an existing one
const targetOwner = getCell(G.cells, x, y)?.owner; // Cannot place a building on an opponent's building
if (targetOwner && targetOwner !== playerID) return INVALID_MOVE; const existingCell = getCell(cells, x, y)!;
if (existingCell.owner && existingCell.owner !== playerID) return false;
const buildingRules = Buildings[building];
if (!("placedOn" in buildingRules)) {
// Cannot place a building on an existing building without a placedOn property
if (existingCell.owner) return false;
} else {
// Can only place a building on an existing building that is a member of placedOn
if (!existingCell.owner) return false;
if (!(buildingRules.placedOn as readonly string[]).includes(existingCell.building)) return false;
}
// Cannot place a building without enough resources // Cannot place a building without enough resources
if (remainingResources(playerID, G.resources, G.moves) < Buildings[building].cost) return INVALID_MOVE; if (resources < buildingRules.cost) return false;
return true;
}
// Cannot place two buildings at the same place function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: number, y: number, building: keyof typeof Buildings) {
// Prevent placing two buildings at the same place
if (G.moves[playerID]!.find((move) => { if (G.moves[playerID]!.find((move) => {
return move.type === "placeBuilding" && move.x === x && move.y === y; return move.type === "placeBuilding" && move.x === x && move.y === y;
})) return INVALID_MOVE; })) return INVALID_MOVE;
if (!canPlaceBuilding(
G.cells,
remainingResources(G.resources, G.moves, playerID),
playerID,
x,
y,
building
)) return INVALID_MOVE;
G.moves[playerID]!.push({ G.moves[playerID]!.push({
type: "placeBuilding", type: "placeBuilding",
x, x,
@ -124,7 +169,7 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
return; return;
} }
function remainingResources(playerID: string, resources: Record<string, number>, moves: Record<string, Move[]>): number { export function remainingResources(resources: Record<string, number>, moves: Record<string, Move[]>, playerID: string): number {
let result = resources[playerID] ?? 0; let result = resources[playerID] ?? 0;
for (const move of moves[playerID] ?? []) { for (const move of moves[playerID] ?? []) {
@ -207,6 +252,21 @@ function areAllPlayersReady(activePlayers: Record<string, string>): boolean {
return true; return true;
} }
export function getResourceGain(cells: Record<string, Cell>, player: string) {
let res = 0;
for (const [_x, _y, cell] of iterateCells(cells)) {
if (cell.owner === player) {
const building = Buildings[cell.building];
if ("gain" in building) {
res += building.gain;
}
}
}
return res;
}
export function* adjacentCells(x: number, y: number): Iterable<[x: number, y: number]> { export function* adjacentCells(x: number, y: number): Iterable<[x: number, y: number]> {
yield [x - 1, y - 1]; yield [x - 1, y - 1];
yield [x, y - 1]; yield [x, y - 1];
@ -228,6 +288,7 @@ export function* iterateCells(grid: Record<string, Cell>) {
function applyMoves(state: State) { function applyMoves(state: State) {
const players = Object.keys(state.moves); const players = Object.keys(state.moves);
const previousCells = {...state.cells};
const moves = state.moves; const moves = state.moves;
state.moves = emptyMoves(players); state.moves = emptyMoves(players);
@ -258,8 +319,14 @@ function applyMoves(state: State) {
const order = orders[0]!; const order = orders[0]!;
const cost = Buildings[order[1].building].cost; const cost = Buildings[order[1].building].cost;
if (state.resources[order[0]]! < cost) continue; if (!canPlaceBuilding(
// TODO: fully check that the building can be placed previousCells,
state.resources[order[0]]!,
order[0],
x,
y,
order[1].building
)) continue;
setCell(state.cells, x, y, { setCell(state.cells, x, y, {
owner: order[0], owner: order[0],

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="173.2"
height="200"
viewBox="0 0 1.732 2"
version="1.1"
id="svg7723"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tile-player1.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7725"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="2.8284271"
inkscape:cx="123.21336"
inkscape:cy="74.776542"
inkscape:window-width="1920"
inkscape:window-height="986"
inkscape:window-x="0"
inkscape:window-y="72"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs7720" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#707070;fill-opacity:1;stroke: var(--player-color, #fff);stroke-width:0.132184;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path10510"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="2.6478419"
sodipodi:cy="0.71499872"
sodipodi:r1="0.60318714"
sodipodi:r2="0.5223754"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 3.1702173,1.0165923 2.6478419,1.3181859 2.1254665,1.0165923 l 0,-0.60318715 0.5223754,-0.30159357 0.5223754,0.30159357 z"
transform="matrix(1.471662,0,0,1.471662,-3.0307033,-0.05223681)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Loading…
Cancel
Save