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()],
|
||||
})
|