Initial version of an idle game
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
12
index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>idle VAMPIRES!</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
2168
package-lock.json
generated
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "idlevamp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.3",
|
||||||
|
"sass": "^1.83.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.3",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
171
src/app.sass
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// reusable: cell internal padding and color
|
||||||
|
|
||||||
|
$font-size-large: 24px
|
||||||
|
$font-size-medium: 20px
|
||||||
|
$font-size-small: 16px
|
||||||
|
|
||||||
|
$bg-hue: 15deg
|
||||||
|
$fg-hue: 280deg
|
||||||
|
|
||||||
|
$bg-hud: oklch(0.8 0.05 $bg-hue)
|
||||||
|
$bg-page: oklch(0.2 0.05 $bg-hue)
|
||||||
|
$bg-tile-insetbutton: oklch(0.7 0.05 $bg-hue)
|
||||||
|
$bg-tile-insetbutton-hover: oklch(0.95 0.03 $bg-hue)
|
||||||
|
$bg-tile: oklch(0.8 0.05 $bg-hue)
|
||||||
|
$fg-shadow: oklch(0.05 0.05 $fg-hue)
|
||||||
|
|
||||||
|
$padding-between: 1em
|
||||||
|
$padding-inside: $padding-between / 2
|
||||||
|
|
||||||
|
$border-radius: 2px
|
||||||
|
|
||||||
|
|
||||||
|
.large
|
||||||
|
font-size: $font-size-large
|
||||||
|
|
||||||
|
.medium
|
||||||
|
font-size: $font-size-medium
|
||||||
|
|
||||||
|
.small
|
||||||
|
font-size: $font-size-small
|
||||||
|
|
||||||
|
@mixin cell-internal
|
||||||
|
border-radius: $border-radius
|
||||||
|
padding: $padding-inside
|
||||||
|
box-sizing: border-box
|
||||||
|
border: 1px $fg-shadow solid
|
||||||
|
box-shadow: 4px 4px 4px 2px $fg-shadow
|
||||||
|
|
||||||
|
/* general layout */
|
||||||
|
body
|
||||||
|
background-color: $bg-page
|
||||||
|
|
||||||
|
.hud
|
||||||
|
margin-bottom: $padding-between
|
||||||
|
background-color: $bg-hud
|
||||||
|
padding: $padding-inside
|
||||||
|
|
||||||
|
.tiles
|
||||||
|
display: flex
|
||||||
|
flex-flow: row wrap
|
||||||
|
column-gap: $padding-between
|
||||||
|
row-gap: $padding-between
|
||||||
|
justify-content: center
|
||||||
|
align-items: start
|
||||||
|
margin-bottom: 1em
|
||||||
|
|
||||||
|
/* tiles */
|
||||||
|
.tile
|
||||||
|
display: block
|
||||||
|
width: 360px
|
||||||
|
@include cell-internal
|
||||||
|
background-color: $bg-tile
|
||||||
|
|
||||||
|
/* action tiles */
|
||||||
|
.actions
|
||||||
|
display: flex
|
||||||
|
flex-flow: column
|
||||||
|
column-gap: $padding-between
|
||||||
|
|
||||||
|
.action
|
||||||
|
border-radius: $border-radius
|
||||||
|
padding: $padding-inside
|
||||||
|
font-size: $font-size-medium
|
||||||
|
display: flex
|
||||||
|
flex-flow: row
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
.text
|
||||||
|
.cost
|
||||||
|
display: flex
|
||||||
|
flex-flow: row
|
||||||
|
|
||||||
|
img.icon
|
||||||
|
height: 0.9em
|
||||||
|
|
||||||
|
&.unavailable
|
||||||
|
opacity: 30%
|
||||||
|
|
||||||
|
&.available
|
||||||
|
cursor: pointer
|
||||||
|
box-sizing: content-box
|
||||||
|
border: 1px $bg-tile-insetbutton solid
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $bg-tile-insetbutton-hover
|
||||||
|
|
||||||
|
|
||||||
|
/* hud layout */
|
||||||
|
.hud
|
||||||
|
display: flex
|
||||||
|
flex-flow: row
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
.message
|
||||||
|
display: block
|
||||||
|
|
||||||
|
.resources
|
||||||
|
font-size: $font-size-medium
|
||||||
|
display: flex
|
||||||
|
flex-flow: row
|
||||||
|
column-gap: $padding-between
|
||||||
|
|
||||||
|
/* icons */
|
||||||
|
img.icon
|
||||||
|
height: 1em
|
||||||
|
|
||||||
|
/* lair tiles */
|
||||||
|
.tile
|
||||||
|
.lair
|
||||||
|
display: flex
|
||||||
|
flex-flow: column
|
||||||
|
row-gap: $padding-inside
|
||||||
|
|
||||||
|
.agent
|
||||||
|
$icon-dimension: 57px
|
||||||
|
display: flex
|
||||||
|
flex-flow: row
|
||||||
|
column-gap: $padding-inside
|
||||||
|
border-radius: $border-radius
|
||||||
|
height: $icon-dimension
|
||||||
|
|
||||||
|
.portrait
|
||||||
|
background-color: $bg-tile-insetbutton
|
||||||
|
border-radius: $border-radius
|
||||||
|
width: $icon-dimension
|
||||||
|
height: $icon-dimension
|
||||||
|
|
||||||
|
.caption
|
||||||
|
flex-grow: 1
|
||||||
|
|
||||||
|
display: flex
|
||||||
|
flex-flow: column
|
||||||
|
justify-content: center
|
||||||
|
row-gap: $padding-inside / 4
|
||||||
|
|
||||||
|
|
||||||
|
.progressBar
|
||||||
|
position: relative
|
||||||
|
padding: 0.15em
|
||||||
|
|
||||||
|
.backdrop
|
||||||
|
border-radius: $border-radius
|
||||||
|
position: absolute
|
||||||
|
left: 0
|
||||||
|
top: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
background-color: $bg-tile-insetbutton
|
||||||
|
|
||||||
|
.caption
|
||||||
|
text-align: center
|
||||||
|
position: relative
|
||||||
|
color: #fff
|
||||||
|
font-size: 0.7em
|
||||||
|
height: 1em
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
.caption
|
||||||
|
color: #000
|
203
src/app.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
// import { useState } from 'preact/hooks'
|
||||||
|
// import preactLogo from './assets/preact.svg'
|
||||||
|
// import viteLogo from '/vite.svg'
|
||||||
|
import './app.sass'
|
||||||
|
import {ComponentChildren} from "preact";
|
||||||
|
import {Icon} from "./icons.tsx";
|
||||||
|
import {Resource, ResourceBundle, resourceTypes} from "./model/resources.ts";
|
||||||
|
import {useModel} from "./hooks.tsx";
|
||||||
|
import {NewLairPrice} from "./model/prices.ts";
|
||||||
|
import {Lair} from "./model/lair.ts";
|
||||||
|
import * as portraits from "./portraits.ts";
|
||||||
|
import {ProgressBar, ProgressBarCallback} from "./progressbar.tsx";
|
||||||
|
import {generateExtensions} from "./model/extensions.ts";
|
||||||
|
import {TaskType} from "./model/agent.ts";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hud />
|
||||||
|
<Tiles />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hud() {
|
||||||
|
const model = useModel();
|
||||||
|
return <div className={"hud"}>
|
||||||
|
<div className={"message"}>
|
||||||
|
<p>Greetings, <strong>{"Master"}</strong>! You have...</p>
|
||||||
|
</div>
|
||||||
|
<div className={"resources"}>
|
||||||
|
{resourceTypes.map((resource) => {
|
||||||
|
let amount = model.resources.get(resource);
|
||||||
|
if (amount == 0) return <></>
|
||||||
|
return <div class={"resource"}>
|
||||||
|
<Icon resource={resource} amount={amount}/> {amount}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function Tiles() {
|
||||||
|
const model = useModel();
|
||||||
|
|
||||||
|
return <div className={"tiles"}>
|
||||||
|
{model.lairs.values.map((i) => <LairTile lair={i} />)}
|
||||||
|
<ActionsTile />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function Tile({children}: { children: ComponentChildren }) {
|
||||||
|
return <div class={"tile"}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LairTile({lair}: {lair: Lair}) {
|
||||||
|
const model = useModel();
|
||||||
|
let extensions = generateExtensions(model);
|
||||||
|
|
||||||
|
let projects = model.lairs.listProjects(extensions, lair.index)
|
||||||
|
let fragments = []
|
||||||
|
for (let part of projects.parts) {
|
||||||
|
if (part.title) {
|
||||||
|
fragments.push(<p className={"medium"}><strong>{part.title}</strong></p>);
|
||||||
|
}
|
||||||
|
let disabled = false;
|
||||||
|
if (lair.activeProject) {
|
||||||
|
disabled = true; // can't switch
|
||||||
|
}
|
||||||
|
fragments.push(<Actions>
|
||||||
|
{part.projects.map((p) =>
|
||||||
|
<Action text={p.label} cost={{}} callback={() => {
|
||||||
|
/*
|
||||||
|
p.callback(model);
|
||||||
|
model.notify();
|
||||||
|
*/
|
||||||
|
model.startWorkingOn(lair.index, p);
|
||||||
|
}} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
</Actions>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tile>
|
||||||
|
<div class={"lair"}>
|
||||||
|
<p><strong>{lair.name}</strong></p>
|
||||||
|
<LairTileAgent agentId={lair.vampire}/>
|
||||||
|
<LairTileAgent agentId={lair.thrall}/>
|
||||||
|
{fragments}
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
// {/* Build rooms */}
|
||||||
|
// <Actions>
|
||||||
|
// <Action text={"Build Grounds."} cost={{}} callback={() => alert("party")}></Action>
|
||||||
|
// </Actions>
|
||||||
|
// {/* Build buildings for room */}
|
||||||
|
// <p className={"medium"}><strong>Grounds</strong></p>
|
||||||
|
// <Actions>
|
||||||
|
// <Action text={"Build party zone."} cost={{[Resource.Wisdom]: 1}} callback={() => alert("party")}></Action>
|
||||||
|
// </Actions>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LairTileAgent({agentId}: {agentId: number | null}) {
|
||||||
|
const model = useModel();
|
||||||
|
|
||||||
|
let name = null;
|
||||||
|
let portrait = portraits.blank;
|
||||||
|
let progressBarCallback: ProgressBarCallback = () => { return {
|
||||||
|
value: null,
|
||||||
|
caption: ""
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (agentId != null) {
|
||||||
|
let agent = model.agents.get(agentId);
|
||||||
|
name = agent.name;
|
||||||
|
portrait = portraits.suggest(agent.name);
|
||||||
|
progressBarCallback = () => {
|
||||||
|
let agentActive = model.agents.get(agentId);
|
||||||
|
|
||||||
|
let currentFractionalTurn = model.turn + model.lifecycle.turnProgress();
|
||||||
|
|
||||||
|
let startedAtTurn = agentActive.activeTask.startedAt;
|
||||||
|
let willFinishAtTurn = model.turn + agentActive.activeTask.remainingTurns;
|
||||||
|
|
||||||
|
let value = (currentFractionalTurn - startedAtTurn) / (willFinishAtTurn - startedAtTurn);
|
||||||
|
// TODO: Get progress for agent
|
||||||
|
return {
|
||||||
|
value: agentActive.activeTask.taskType == TaskType.Idle ? null : value,
|
||||||
|
caption: agentActive.activeTask.caption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayName = name ?? "-"
|
||||||
|
let portraitAltText = `portrait (${name ?? 'blank'})`
|
||||||
|
|
||||||
|
return <div class={"agent"}>
|
||||||
|
<img class={"portrait"} src={portrait} alt={portraitAltText} />
|
||||||
|
<div class={"caption"}>
|
||||||
|
<div class={"medium"}>{displayName}</div>
|
||||||
|
<ProgressBar callback={progressBarCallback}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionsTile({}) {
|
||||||
|
const model = useModel();
|
||||||
|
|
||||||
|
return <Tile>
|
||||||
|
<Actions>
|
||||||
|
<Action text={"Build new lair."} cost={NewLairPrice} callback={
|
||||||
|
() => model.buildNewLair()
|
||||||
|
}/>
|
||||||
|
</Actions>
|
||||||
|
</Tile>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Actions({children}: {children: ComponentChildren}) {
|
||||||
|
return <div class={"actions"}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Action({text, cost, callback, disabled}: {text: string, cost: ResourceBundle, callback: () => void, disabled?: boolean}) {
|
||||||
|
const model = useModel();
|
||||||
|
|
||||||
|
let available = true;
|
||||||
|
if (disabled) {
|
||||||
|
available = false
|
||||||
|
}
|
||||||
|
if (callback == null) {
|
||||||
|
available = false;
|
||||||
|
}
|
||||||
|
if (!model.resources.canSpend(cost)) {
|
||||||
|
available = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div
|
||||||
|
className={"action " + (available ? "available " : "unavailable ")}
|
||||||
|
onClick={available ? callback : undefined}
|
||||||
|
>
|
||||||
|
<div className={"text"}>
|
||||||
|
<p>{text}</p>
|
||||||
|
</div>
|
||||||
|
<div className={"cost"}>
|
||||||
|
<ResourceCostFragment cost={cost} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceCostFragment({cost}: {cost: ResourceBundle}) {
|
||||||
|
return <>{resourceTypes.map((resource: Resource) => {
|
||||||
|
let amount = cost[resource];
|
||||||
|
if (amount == 0 || amount == undefined) return <></>
|
||||||
|
return <div class={"resource"}>
|
||||||
|
<Icon resource={resource} amount={amount}/> {amount}
|
||||||
|
</div>
|
||||||
|
})}</>;
|
||||||
|
}
|
BIN
src/assets/icons/blood.png
Normal file
After Width: | Height: | Size: 707 B |
BIN
src/assets/icons/enforcer.png
Normal file
After Width: | Height: | Size: 408 B |
BIN
src/assets/icons/gold/gold_pile_1.png
Normal file
After Width: | Height: | Size: 180 B |
BIN
src/assets/icons/gold/gold_pile_10.png
Normal file
After Width: | Height: | Size: 319 B |
BIN
src/assets/icons/gold/gold_pile_16.png
Normal file
After Width: | Height: | Size: 358 B |
BIN
src/assets/icons/gold/gold_pile_19.png
Normal file
After Width: | Height: | Size: 369 B |
BIN
src/assets/icons/gold/gold_pile_2.png
Normal file
After Width: | Height: | Size: 205 B |
BIN
src/assets/icons/gold/gold_pile_23.png
Normal file
After Width: | Height: | Size: 379 B |
BIN
src/assets/icons/gold/gold_pile_25.png
Normal file
After Width: | Height: | Size: 375 B |
BIN
src/assets/icons/gold/gold_pile_3.png
Normal file
After Width: | Height: | Size: 232 B |
BIN
src/assets/icons/gold/gold_pile_4.png
Normal file
After Width: | Height: | Size: 251 B |
BIN
src/assets/icons/gold/gold_pile_5.png
Normal file
After Width: | Height: | Size: 243 B |
BIN
src/assets/icons/gold/gold_pile_6.png
Normal file
After Width: | Height: | Size: 260 B |
BIN
src/assets/icons/gold/gold_pile_7.png
Normal file
After Width: | Height: | Size: 279 B |
BIN
src/assets/icons/gold/gold_pile_8.png
Normal file
After Width: | Height: | Size: 267 B |
BIN
src/assets/icons/gold/gold_pile_9.png
Normal file
After Width: | Height: | Size: 299 B |
BIN
src/assets/icons/legend.png
Normal file
After Width: | Height: | Size: 257 B |
BIN
src/assets/icons/thrall.png
Normal file
After Width: | Height: | Size: 645 B |
BIN
src/assets/icons/wisdom.png
Normal file
After Width: | Height: | Size: 314 B |
BIN
src/assets/portraits/blank.png
Normal file
After Width: | Height: | Size: 527 B |
BIN
src/assets/portraits/pyrex.png
Normal file
After Width: | Height: | Size: 761 KiB |
55
src/hooks.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {Model} from "./model/model.ts";
|
||||||
|
import {ComponentChildren} from "preact";
|
||||||
|
import {Context, createContext, useContext, useEffect, useState} from "react";
|
||||||
|
import {Supervisor} from "./model/supervisor.ts";
|
||||||
|
import {TurnDuration} from "./model/lifecycle.ts";
|
||||||
|
|
||||||
|
function loadAndSuperviseModel(supervisor: Supervisor): Model {
|
||||||
|
let model = new Model();
|
||||||
|
model.setSupervisor(supervisor);
|
||||||
|
let sched = (turnProgress: number) => {
|
||||||
|
let milliseconds = TurnDuration * (1 - turnProgress);
|
||||||
|
setTimeout(upd, milliseconds)
|
||||||
|
}
|
||||||
|
let upd = () => {
|
||||||
|
model.nextTurn();
|
||||||
|
sched(0.0);
|
||||||
|
}
|
||||||
|
sched(0.0);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
let SupervisorContext: Context<[Supervisor, Model] | undefined> = createContext(
|
||||||
|
undefined as [Supervisor, Model] | undefined);
|
||||||
|
|
||||||
|
export function HasModel({children}: {children: ComponentChildren}) {
|
||||||
|
let [val, setVal] =
|
||||||
|
useState(undefined as [Supervisor, Model] | undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
let supervisor = new Supervisor();
|
||||||
|
let model = loadAndSuperviseModel(supervisor);
|
||||||
|
setVal([supervisor, model]);
|
||||||
|
return () => setVal(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (val == undefined) {
|
||||||
|
return <></>
|
||||||
|
} else {
|
||||||
|
return <SupervisorContext.Provider value = {val}>{children}</SupervisorContext.Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModel(): Model {
|
||||||
|
const val = useContext(SupervisorContext)
|
||||||
|
if (val == undefined) {
|
||||||
|
throw new Error("should never be undefined due to the structure of HasModel")
|
||||||
|
}
|
||||||
|
|
||||||
|
let [supervisor, model] = val;
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let handle = supervisor.onNotify(() => setVersion(version + 1));
|
||||||
|
return () => { supervisor.removeOnNotify(handle); }
|
||||||
|
});
|
||||||
|
return model
|
||||||
|
}
|
29
src/icons.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import iconGold from "./assets/icons/gold/gold_pile_25.png";
|
||||||
|
import iconBlood from "./assets/icons/blood.png";
|
||||||
|
import iconEnforcer from "./assets/icons/enforcer.png";
|
||||||
|
import iconThrall from "./assets/icons/thrall.png";
|
||||||
|
import iconLegend from "./assets/icons/legend.png";
|
||||||
|
import iconWisdom from "./assets/icons/wisdom.png";
|
||||||
|
import {Resource} from "./model/resources.ts";
|
||||||
|
|
||||||
|
export function iconFilename(resource: Resource, _amount: number) {
|
||||||
|
switch (resource) {
|
||||||
|
case Resource.Gold:
|
||||||
|
// TODO: Consider other icons
|
||||||
|
return iconGold;
|
||||||
|
case Resource.Blood:
|
||||||
|
return iconBlood;
|
||||||
|
case Resource.Enforcer:
|
||||||
|
return iconEnforcer;
|
||||||
|
case Resource.Thrall:
|
||||||
|
return iconThrall;
|
||||||
|
case Resource.Legend:
|
||||||
|
return iconLegend;
|
||||||
|
case Resource.Wisdom:
|
||||||
|
return iconWisdom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({resource, amount}: {resource: Resource, amount: number}) {
|
||||||
|
return <img alt={`${resource} (${amount})`} class={"icon"} src={iconFilename(resource, amount)} />;
|
||||||
|
}
|
12
src/index.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-variant: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
7
src/main.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { render } from 'preact'
|
||||||
|
import './minireset.css'
|
||||||
|
import './index.css'
|
||||||
|
import { App } from './app.tsx'
|
||||||
|
import {HasModel} from "./hooks.tsx";
|
||||||
|
|
||||||
|
render(<HasModel><App /></HasModel>, document.getElementById('app')!)
|
1
src/minireset.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */html,body,p,ol,ul,li,dl,dt,dd,blockquote,figure,fieldset,legend,textarea,pre,iframe,hr,h1,h2,h3,h4,h5,h6{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal}ul{list-style:none}button,input,select{margin:0}html{box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}
|
105
src/model/agent.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import {Model} from "./model.ts";
|
||||||
|
import {ActiveProject} from "./lair.ts";
|
||||||
|
|
||||||
|
export enum AgentRole {
|
||||||
|
Vampire,
|
||||||
|
Thrall
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Agent = {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
activeTask: ActiveTask
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaskType {
|
||||||
|
Idle = 0,
|
||||||
|
Project = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveTask = {
|
||||||
|
taskType: TaskType
|
||||||
|
caption: string
|
||||||
|
startedAt: number,
|
||||||
|
remainingTurns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Agents {
|
||||||
|
values: Array<Agent> = []
|
||||||
|
|
||||||
|
get(agentId: number): Agent {
|
||||||
|
return this.values[agentId];
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(role: AgentRole): number {
|
||||||
|
let id = this.values.length;
|
||||||
|
let agent = {
|
||||||
|
index: id,
|
||||||
|
name: "Pyrex",
|
||||||
|
activeTask: {
|
||||||
|
taskType: TaskType.Idle,
|
||||||
|
caption: "Idle",
|
||||||
|
startedAt: 0,
|
||||||
|
remainingTurns: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.values.push(agent);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeSchedule(model: Model) {
|
||||||
|
// first of all: which agents have a project?
|
||||||
|
let onProject: Record<number, [number, ActiveProject]> = {};
|
||||||
|
for (let lair of model.lairs.values) {
|
||||||
|
if (lair.activeProject) {
|
||||||
|
if (lair.vampire != null) {
|
||||||
|
onProject[lair.vampire] = [lair.index, lair.activeProject];
|
||||||
|
}
|
||||||
|
if (lair.thrall != null) {
|
||||||
|
onProject[lair.thrall] = [lair.index, lair.activeProject];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// who has nothing to do?
|
||||||
|
let unaccountedFor = [];
|
||||||
|
for (let agent of this.values) {
|
||||||
|
if (!onProject[agent.index]) {
|
||||||
|
unaccountedFor.push(agent.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i of unaccountedFor) {
|
||||||
|
this.transitionToIdle(model, i);
|
||||||
|
}
|
||||||
|
for (let agent of this.values) {
|
||||||
|
if (onProject[agent.index]) {
|
||||||
|
this.transitionToWork(model, agent.index, onProject[agent.index][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionToWork(model: Model, agent: number, project: ActiveProject) {
|
||||||
|
let caption = project.verb;
|
||||||
|
if (this.values[agent].activeTask.taskType == TaskType.Project) {
|
||||||
|
this.values[agent].activeTask.caption = caption;
|
||||||
|
this.values[agent].activeTask.remainingTurns = project.remainingTurns;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.values[agent].activeTask = {
|
||||||
|
taskType: TaskType.Project,
|
||||||
|
caption: caption,
|
||||||
|
startedAt: model.turn,
|
||||||
|
remainingTurns: project.remainingTurns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionToIdle(model: Model, agent: number) {
|
||||||
|
this.values[agent].activeTask = {
|
||||||
|
taskType: TaskType.Idle,
|
||||||
|
caption: "Idle",
|
||||||
|
startedAt: model.turn,
|
||||||
|
remainingTurns: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
87
src/model/extensions.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// == these are the room types ==
|
||||||
|
import {ResourceBundle} from "./resources.ts";
|
||||||
|
import {Model} from "./model.ts";
|
||||||
|
|
||||||
|
export class LairExtensions {
|
||||||
|
extensions: Array<LairExtensionType> = []
|
||||||
|
extensionObjects: Record<string, Array<LairExtensionObject>> = {}
|
||||||
|
objectInteractions: Record<string, Array<LairExtensionInteraction>> = {}
|
||||||
|
|
||||||
|
createExtension(extension: LairExtensionType): string {
|
||||||
|
this.extensions.push(extension);
|
||||||
|
return extension.id
|
||||||
|
}
|
||||||
|
|
||||||
|
addObjectToExtension(extensionId: string, object: LairExtensionObject): string {
|
||||||
|
this.extensionObjects[extensionId] = this.extensionObjects[extensionId] ?? []
|
||||||
|
this.extensionObjects[extensionId].push(object)
|
||||||
|
return object.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
addInteractionToObject(objectId: string, interaction: LairExtensionInteraction): string {
|
||||||
|
this.objectInteractions[objectId] = this.objectInteractions[objectId] ?? []
|
||||||
|
this.objectInteractions[objectId].push(interaction);
|
||||||
|
return interaction.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LairExtensionType = {
|
||||||
|
id: string,
|
||||||
|
projectName: string,
|
||||||
|
verb: string,
|
||||||
|
extensionName: string,
|
||||||
|
cost: ResourceBundle,
|
||||||
|
turns: number,
|
||||||
|
payoff: ((model: Model) => void) | null,
|
||||||
|
}
|
||||||
|
export type LairExtensionObject = {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
verb: string,
|
||||||
|
cost: ResourceBundle,
|
||||||
|
turns: number,
|
||||||
|
payoff: ((model: Model) => void) | null,
|
||||||
|
}
|
||||||
|
export type LairExtensionInteraction = {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
verb: string,
|
||||||
|
cost: ResourceBundle,
|
||||||
|
turns: number,
|
||||||
|
payoff: ((model: Model) => void) | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateExtensions(_model: Model): LairExtensions {
|
||||||
|
let extensions = new LairExtensions();
|
||||||
|
|
||||||
|
const EXT_GROUNDS = extensions.createExtension({
|
||||||
|
id: "grounds",
|
||||||
|
projectName: "Renovate the grounds.",
|
||||||
|
verb: "Renovating the grounds",
|
||||||
|
extensionName: "Grounds",
|
||||||
|
cost: {},
|
||||||
|
turns: 5,
|
||||||
|
payoff: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const OBJ_BOUNCY_CASTLE = extensions.addObjectToExtension(EXT_GROUNDS, {
|
||||||
|
id: "bouncyCastle",
|
||||||
|
name: "Build a bouncy castle.",
|
||||||
|
verb: "Building a bouncy castle",
|
||||||
|
cost: {},
|
||||||
|
turns: 5,
|
||||||
|
payoff: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const INT_BOUNCE = extensions.addInteractionToObject(OBJ_BOUNCY_CASTLE, {
|
||||||
|
id: "bounce",
|
||||||
|
name: "Bounce in the bouncy castle.",
|
||||||
|
verb: "Bouncing in the bouncy castle",
|
||||||
|
cost: {},
|
||||||
|
turns: 5,
|
||||||
|
payoff: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
}
|
||||||
|
|
211
src/model/lair.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import {generateExtensions, LairExtensions} from "./extensions.ts";
|
||||||
|
import {Model} from "./model.ts";
|
||||||
|
|
||||||
|
export type Lair = {
|
||||||
|
index: number,
|
||||||
|
name: string,
|
||||||
|
vampire: number | null
|
||||||
|
thrall: number | null
|
||||||
|
|
||||||
|
extensions: Array<string>,
|
||||||
|
objects: Array<string>,
|
||||||
|
progress: Record<string, number>,
|
||||||
|
activeProject: ActiveProject | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveProject = {
|
||||||
|
id: string
|
||||||
|
verb: string
|
||||||
|
remainingTurns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Lairs {
|
||||||
|
values: Array<Lair> = []
|
||||||
|
|
||||||
|
generate(): number {
|
||||||
|
let i = this.values.length;
|
||||||
|
this.values.push(generateLair(i));
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
setVampire(lair: number, vampire: number | null) {
|
||||||
|
this.values[lair].vampire = vampire;
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorkingOn(lair: number, project: LairProject) {
|
||||||
|
this.values[lair].activeProject = {
|
||||||
|
id: project.id,
|
||||||
|
verb: project.verb,
|
||||||
|
remainingTurns: project.turns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProjects(model: Model) {
|
||||||
|
for (let lair of this.values) {
|
||||||
|
let project = lair.activeProject;
|
||||||
|
if (project == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (project.remainingTurns > 0) {
|
||||||
|
project.remainingTurns -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let extensions = generateExtensions(model);
|
||||||
|
if (project.remainingTurns == 0) {
|
||||||
|
let projects = this.listProjects(extensions, lair.index)
|
||||||
|
let realProject = projects.getById(project.id);
|
||||||
|
realProject?.callback(model);
|
||||||
|
lair.activeProject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
listProjects(extensions: LairExtensions, lairIx: number): LairProjects {
|
||||||
|
let projects = new LairProjects();
|
||||||
|
let lair = this.values[lairIx];
|
||||||
|
|
||||||
|
// first: what extensions can be built?
|
||||||
|
let unbuilt = []
|
||||||
|
for (let extension of extensions.extensions) {
|
||||||
|
if (lair.extensions.indexOf(extension.id) >= 0) {
|
||||||
|
// already built
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unbuilt.push(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unbuilt.length > 0) {
|
||||||
|
let section = new LairProjectsSection(null);
|
||||||
|
unbuilt.forEach((u) => {
|
||||||
|
section.add(new LairProject(
|
||||||
|
`addExtension:${u.id}`,
|
||||||
|
u.projectName,
|
||||||
|
u.verb,
|
||||||
|
u.turns,
|
||||||
|
(model) => {
|
||||||
|
model.lairs.values[lairIx].extensions.push(u.id)
|
||||||
|
if (u.payoff != null) {
|
||||||
|
u.payoff(model);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
projects.add(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// next: in each extension, which objects can we build or interact with?
|
||||||
|
for (let extensionId of lair.extensions) {
|
||||||
|
// find the base implementation
|
||||||
|
let extensionData = null;
|
||||||
|
for (let realExtension of extensions.extensions) {
|
||||||
|
if (realExtension.id == extensionId) {
|
||||||
|
extensionData = realExtension;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extensionData == null) { continue; }
|
||||||
|
|
||||||
|
// now figure out what objects we have (if any)
|
||||||
|
let section = new LairProjectsSection(extensionData.extensionName);
|
||||||
|
(extensions.extensionObjects[extensionId] ?? []).forEach((o) => {
|
||||||
|
let wasBuilt = lair.objects.indexOf(o.id) >= 0;
|
||||||
|
|
||||||
|
if (!wasBuilt) {
|
||||||
|
section.add(new LairProject(
|
||||||
|
`build:${o.id}`,
|
||||||
|
o.name,
|
||||||
|
o.verb,
|
||||||
|
o.turns,
|
||||||
|
(model) => {
|
||||||
|
model.lairs.values[lairIx].objects.push(o.id)
|
||||||
|
if (o.payoff != null) {
|
||||||
|
o.payoff(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
(extensions.objectInteractions[o.id] ?? []).forEach((i) => {
|
||||||
|
section.add(new LairProject(
|
||||||
|
`interact:${o.id}`,
|
||||||
|
i.name,
|
||||||
|
i.verb,
|
||||||
|
i.turns,
|
||||||
|
(model) => {
|
||||||
|
if (i.payoff != null) {
|
||||||
|
i.payoff(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
projects.add(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateLair(index: number) {
|
||||||
|
return {
|
||||||
|
index: index,
|
||||||
|
name: "Pyrex's Castle",
|
||||||
|
vampire: null,
|
||||||
|
thrall: null,
|
||||||
|
|
||||||
|
extensions: [],
|
||||||
|
objects: [],
|
||||||
|
progress: {},
|
||||||
|
activeProject: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == this is the menu, which is fairly closely coupled to React ==
|
||||||
|
export class LairProjects {
|
||||||
|
parts: Array<LairProjectsSection> = [];
|
||||||
|
|
||||||
|
add(section: LairProjectsSection) {
|
||||||
|
if (section.projects.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.parts.push(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): LairProject | null {
|
||||||
|
for (let part of this.parts) {
|
||||||
|
for (let project of part.projects) {
|
||||||
|
if (project.id == id) { return project; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LairProjectsSection {
|
||||||
|
title: string | null
|
||||||
|
projects: Array<LairProject> = []
|
||||||
|
|
||||||
|
constructor(title: string | null) {
|
||||||
|
this.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
add(project: LairProject) {
|
||||||
|
this.projects.push(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LairProject {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
verb: string
|
||||||
|
turns: number
|
||||||
|
callback: (model: Model) => void
|
||||||
|
|
||||||
|
constructor(id: string, label: string, verb: string, turns: number, callback: (model: Model) => void) {
|
||||||
|
this.id = id
|
||||||
|
this.label = label
|
||||||
|
this.verb = verb
|
||||||
|
this.turns = turns
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
}
|
30
src/model/lifecycle.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {Model} from "./model.ts";
|
||||||
|
import {Resource} from "./resources.ts";
|
||||||
|
|
||||||
|
export const TurnDuration: number = 1000;
|
||||||
|
|
||||||
|
export class Lifecycle {
|
||||||
|
didSetup: boolean
|
||||||
|
lastTurn: number = Date.now()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.didSetup = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGameIfUnstarted(model: Model) {
|
||||||
|
if (this.didSetup) { return }
|
||||||
|
this.didSetup = true;
|
||||||
|
|
||||||
|
// set up game
|
||||||
|
model.resources.receive({[Resource.Enforcer]: 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
recordTurn() {
|
||||||
|
this.lastTurn = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
turnProgress() {
|
||||||
|
return Math.max(Math.min(1.0, (Date.now() - this.lastTurn) / TurnDuration), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
51
src/model/model.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import {Supervisor} from "./supervisor.ts";
|
||||||
|
import {Resources} from "./resources.ts";
|
||||||
|
import {Lairs, LairProject} from "./lair.ts";
|
||||||
|
import {NewLairPrice} from "./prices.ts";
|
||||||
|
import {Lifecycle} from "./lifecycle.ts";
|
||||||
|
import {AgentRole, Agents} from "./agent.ts";
|
||||||
|
|
||||||
|
|
||||||
|
export class Model {
|
||||||
|
#supervisor?: Supervisor
|
||||||
|
|
||||||
|
turn: number = 0
|
||||||
|
lifecycle: Lifecycle = new Lifecycle()
|
||||||
|
resources: Resources = new Resources()
|
||||||
|
agents: Agents = new Agents()
|
||||||
|
lairs: Lairs = new Lairs()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.lifecycle.setupGameIfUnstarted(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSupervisor(supervisor: Supervisor) {
|
||||||
|
this.#supervisor = supervisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
this.#supervisor?.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTurn() {
|
||||||
|
this.turn += 1
|
||||||
|
this.lifecycle.recordTurn();
|
||||||
|
this.lairs.updateProjects(this);
|
||||||
|
this.agents.computeSchedule(this);
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
buildNewLair() {
|
||||||
|
if (!this.resources.spend(NewLairPrice)) { return; }
|
||||||
|
|
||||||
|
let vampire = this.agents.generate(AgentRole.Vampire);
|
||||||
|
let lair = this.lairs.generate();
|
||||||
|
this.lairs.setVampire(lair, vampire);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorkingOn(lair: number, project: LairProject) {
|
||||||
|
this.lairs.startWorkingOn(lair, project)
|
||||||
|
this.notify()
|
||||||
|
}
|
||||||
|
}
|
3
src/model/prices.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import {Resource, ResourceBundle} from "./resources.ts";
|
||||||
|
|
||||||
|
export const NewLairPrice: ResourceBundle = {[Resource.Enforcer]: 1}
|
42
src/model/resources.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
export enum Resource {
|
||||||
|
Gold, Blood, Enforcer, Thrall, Legend, Wisdom
|
||||||
|
}
|
||||||
|
export var resourceTypes: Array<Resource> = [Resource.Gold, Resource.Blood, Resource.Enforcer, Resource.Thrall, Resource.Legend, Resource.Wisdom];
|
||||||
|
|
||||||
|
export type ResourceBundle = {[t in Resource]?: number}
|
||||||
|
|
||||||
|
export class Resources {
|
||||||
|
state: {[t in Resource]?: number} = {}
|
||||||
|
|
||||||
|
get(resource: Resource): number {
|
||||||
|
return this.state[resource] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
set(resource: Resource, amount: number) {
|
||||||
|
this.state[resource] = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
canSpend(bundle: ResourceBundle): boolean {
|
||||||
|
for (let rt of resourceTypes) {
|
||||||
|
if (this.get(rt) < (bundle[rt] ?? 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
receive(bundle: ResourceBundle) {
|
||||||
|
for (let rt of resourceTypes) {
|
||||||
|
this.set(rt, this.get(rt) + (bundle[rt] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spend(bundle: ResourceBundle): boolean {
|
||||||
|
if (!this.canSpend(bundle)) { return false; }
|
||||||
|
for (let rt of resourceTypes) {
|
||||||
|
this.set(rt, this.get(rt) - (bundle[rt] ?? 0));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
24
src/model/supervisor.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export class Supervisor {
|
||||||
|
#subscriberI: number = 0
|
||||||
|
#subscribers: Record<number, () => void> = {}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
for (let i in this.#subscribers) {
|
||||||
|
this.#subscribers[i]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNotify(cb: () => void) {
|
||||||
|
let i = this.#subscriberI++;
|
||||||
|
this.#subscribers[i] = cb;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOnNotify(handle: number) {
|
||||||
|
delete this.#subscribers[handle]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
13
src/portraits.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import blankPortrait from "./assets/portraits/blank.png"
|
||||||
|
import pyrexPortrait from "./assets/portraits/pyrex.png"
|
||||||
|
|
||||||
|
export function suggest(name: string ) {
|
||||||
|
switch (name) {
|
||||||
|
case "Pyrex": return pyrex;
|
||||||
|
default: return blank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export var blank = blankPortrait;
|
||||||
|
export var pyrex = pyrexPortrait;
|
56
src/progressbar.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { MutableRef } from "preact/hooks";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
|
|
||||||
|
export type ProgressBarCallback = () => {
|
||||||
|
value: number | null,
|
||||||
|
caption: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({callback}: { callback: ProgressBarCallback }) {
|
||||||
|
const canvasRef: MutableRef<HTMLCanvasElement | null> = useRef(null);
|
||||||
|
const msgRef: MutableRef<HTMLDivElement | null> = useRef(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled: boolean = false
|
||||||
|
let animate = () => {
|
||||||
|
if (canceled || canvasRef.current == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement = canvasRef.current;
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = 1
|
||||||
|
let ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (ctx == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {value, caption} = callback();
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
setEnabled(false);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = "#000";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = "#f00";
|
||||||
|
ctx.fillRect(0, 0, value * canvas.width, canvas.height);
|
||||||
|
setEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(caption);
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
return () => { canceled = true; }
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div class={"progressBar " + (enabled ? "enabled" : "disabled")}>
|
||||||
|
<canvas ref={canvasRef} className={"backdrop"}></canvas>
|
||||||
|
<div ref={msgRef} className={"caption small"}>{text}</div>
|
||||||
|
</div>
|
||||||
|
}
|
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
31
tsconfig.app.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"react": ["./node_modules/preact/compat/"],
|
||||||
|
"react-dom": ["./node_modules/preact/compat/"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import preact from '@preact/preset-vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
})
|