parent
fce9712698
commit
59c6fed541
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 2.0 KiB |
@ -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 = "/";
|
After Width: | Height: | Size: 2.0 KiB |
Loading…
Reference in new issue