Initial version of an idle game

This commit is contained in:
Pyrex 2024-12-31 23:09:09 -08:00
commit 98bd863f24
47 changed files with 3394 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

BIN
src/assets/icons/legend.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

BIN
src/assets/icons/thrall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
src/assets/icons/wisdom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

55
src/hooks.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

31
tsconfig.app.json Normal file
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [preact()],
})