Compare commits
25 Commits
fix-mapgen
...
main
Author | SHA1 | Date | |
---|---|---|---|
260422fabe | |||
a2e09e5237 | |||
8260bf8b21 | |||
19b097a0bd | |||
897133f8de | |||
c285c76096 | |||
7d0e5566f8 | |||
02d32266e9 | |||
770ff68a62 | |||
a57cc50803 | |||
f2f20b820e | |||
81f498c804 | |||
5ab3778074 | |||
18ce5875c5 | |||
4616945b12 | |||
b0226d5d4b | |||
a7024728ba | |||
d031a6acbe | |||
9024d67114 | |||
2923fd0a11 | |||
6ede822d4a | |||
9d4a9bc0b1 | |||
bccd7661b8 | |||
f1872c74ad | |||
1ffc0518b2 |
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Prototype 2</title>
|
||||
<title>FLEDGLING</title>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" style="cursor: none"></canvas>
|
||||
|
BIN
packaging/gif1.gif
Normal file
After Width: | Height: | Size: 163 KiB |
BIN
packaging/gif2.gif
Normal file
After Width: | Height: | Size: 73 KiB |
BIN
packaging/gif3.gif
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
packaging/thumbnail.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 471 B After Width: | Height: | Size: 479 B |
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 168 B |
BIN
src/art/sounds/bite.mp3
Normal file
BIN
src/art/sounds/collect.mp3
Normal file
BIN
src/art/sounds/death.mp3
Normal file
BIN
src/art/sounds/dig.mp3
Normal file
BIN
src/art/sounds/ending.mp3
Normal file
BIN
src/art/sounds/recruit.mp3
Normal file
BIN
src/art/sounds/reward_big.mp3
Normal file
BIN
src/art/sounds/reward_huge.mp3
Normal file
BIN
src/art/sounds/reward_medium.mp3
Normal file
BIN
src/art/sounds/reward_small.mp3
Normal file
BIN
src/art/sounds/silence.mp3
Normal file
BIN
src/art/sounds/sleep.mp3
Normal file
BIN
src/art/sounds/vn_bat.mp3
Normal file
BIN
src/art/sounds/vn_breath.mp3
Normal file
BIN
src/art/sounds/vn_dance.mp3
Normal file
BIN
src/art/sounds/vn_doorbell.mp3
Normal file
BIN
src/art/sounds/vn_ghost.mp3
Normal file
BIN
src/art/sounds/vn_page.mp3
Normal file
BIN
src/art/sounds/vn_phone.mp3
Normal file
Before Width: | Height: | Size: 591 B After Width: | Height: | Size: 590 B |
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 679 B |
Before Width: | Height: | Size: 648 B After Width: | Height: | Size: 655 B |
@ -1,12 +1,6 @@
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { AlignX, AlignY, Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import {
|
||||
BG_INSET,
|
||||
FG_BOLD,
|
||||
FG_TEXT,
|
||||
FG_TEXT_DISABLED,
|
||||
FG_TEXT_ENDORSED,
|
||||
} from "./colors.ts";
|
||||
import { C } from "./colors.ts";
|
||||
import { D } from "./engine/public.ts";
|
||||
|
||||
export function addButton(
|
||||
@ -31,18 +25,18 @@ export function addButton(
|
||||
drawpile.addClickable(
|
||||
0,
|
||||
(hover) => {
|
||||
let [bg, fg, fgLabel] = [BG_INSET, FG_TEXT, FG_BOLD];
|
||||
let [bg, fg, fgLabel] = [C.BG_UI, C.FG_TEXT, C.FG_BOLD];
|
||||
if (!enabled) {
|
||||
fgLabel = FG_TEXT_DISABLED;
|
||||
fgLabel = C.FG_TEXT_DISABLED;
|
||||
}
|
||||
|
||||
if (enabled && options?.endorse) {
|
||||
fg = FG_TEXT_ENDORSED;
|
||||
fgLabel = FG_TEXT_ENDORSED;
|
||||
fg = C.FG_TEXT_ENDORSED;
|
||||
fgLabel = C.FG_TEXT_ENDORSED;
|
||||
}
|
||||
|
||||
if (hover) {
|
||||
[bg, fg, fgLabel] = [FG_BOLD, BG_INSET, BG_INSET];
|
||||
[bg, fg, fgLabel] = [C.FG_BOLD, C.BG_UI, C.BG_UI];
|
||||
}
|
||||
D.fillRect(
|
||||
topLeftPadded.offset(new Point(-1, -1)),
|
||||
|
@ -3,10 +3,10 @@ import { CheckData, CheckDataOption, ChoiceOption } from "./newmap.ts";
|
||||
import { getPartLocation, withCamera } from "./layout.ts";
|
||||
import { AlignX, AlignY, Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { D } from "./engine/public.ts";
|
||||
import { BG_INSET, FG_BOLD } from "./colors.ts";
|
||||
import { addButton } from "./button.ts";
|
||||
import { getSkills } from "./skills.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
export class CheckModal {
|
||||
#drawpile: DrawPile;
|
||||
@ -54,17 +54,22 @@ export class CheckModal {
|
||||
|
||||
let size = this.#size;
|
||||
this.#drawpile.add(0, () => {
|
||||
D.fillRect(new Point(-4, -4), size.add(new Size(8, 8)), BG_INSET);
|
||||
D.fillRect(new Point(-4, -4), size.add(new Size(8, 8)), C.BG_UI);
|
||||
});
|
||||
|
||||
let success = this.#success;
|
||||
if (success) {
|
||||
this.#drawpile.add(0, () => {
|
||||
D.drawText(success, new Point(size.w / 2, (size.h - 64) / 2), FG_BOLD, {
|
||||
forceWidth: size.w,
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
});
|
||||
D.drawText(
|
||||
success,
|
||||
new Point(size.w / 2, (size.h - 64) / 2),
|
||||
C.FG_BOLD,
|
||||
{
|
||||
forceWidth: size.w,
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
},
|
||||
);
|
||||
});
|
||||
addButton(
|
||||
this.#drawpile,
|
||||
@ -80,11 +85,16 @@ export class CheckModal {
|
||||
|
||||
let labelText = check.label;
|
||||
this.#drawpile.add(0, () => {
|
||||
D.drawText(labelText, new Point(size.w / 2, (size.h - 64) / 2), FG_BOLD, {
|
||||
forceWidth: size.w,
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
});
|
||||
D.drawText(
|
||||
labelText,
|
||||
new Point(size.w / 2, (size.h - 64) / 2),
|
||||
C.FG_BOLD,
|
||||
{
|
||||
forceWidth: size.w,
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let options = check.options;
|
||||
|
240
src/colors.ts
@ -1,46 +1,206 @@
|
||||
import { Color } from "./engine/datatypes.ts";
|
||||
import { Stat } from "./datatypes.ts";
|
||||
import { maybeGetHuntMode } from "./huntmode.ts";
|
||||
import { getEndgameModal } from "./endgamemodal.ts";
|
||||
import { getVNModal } from "./vnmodal.ts";
|
||||
|
||||
export const BG_OUTER = Color.parseHexCode("#143464");
|
||||
export const BG_WALL_OR_UNREVEALED = Color.parseHexCode("#143464");
|
||||
export const BG_INSET = Color.parseHexCode("#242234");
|
||||
export const FG_TEXT = Color.parseHexCode("#c0c0c0");
|
||||
export const FG_TEXT_DISABLED = Color.parseHexCode("#808080");
|
||||
export const FG_TOO_EXPENSIVE = Color.parseHexCode("#ff8080");
|
||||
export const FG_TEXT_ENDORSED = Color.parseHexCode("#80ff80");
|
||||
export const FG_BOLD = Color.parseHexCode("#ffffff");
|
||||
export const BG_CEILING = Color.parseHexCode("#143464");
|
||||
export const FG_MOULDING = FG_TEXT;
|
||||
export type Microtheme = {
|
||||
SKY0: Color; // outer, less dark
|
||||
FLOOR0: Color; // floor, even less dark
|
||||
|
||||
// stat colors
|
||||
export const SWATCH_EXP: [Color, Color] = [
|
||||
Color.parseHexCode("#b9bffb"),
|
||||
Color.parseHexCode("#e3e6ff"),
|
||||
];
|
||||
WALL0: Color; // darkest (ex. the underside of something)
|
||||
WALL1: Color; // darkest (ex. the underside of something)
|
||||
|
||||
export const SWATCH_AGI: [Color, Color] = [
|
||||
Color.parseHexCode("#df3e23"),
|
||||
Color.parseHexCode("#fa6a0a"),
|
||||
];
|
||||
|
||||
export const SWATCH_INT: [Color, Color] = [
|
||||
Color.parseHexCode("#285cc4"),
|
||||
Color.parseHexCode("#249fde"),
|
||||
];
|
||||
|
||||
export const SWATCH_CHA: [Color, Color] = [
|
||||
Color.parseHexCode("#793a80"),
|
||||
Color.parseHexCode("#bc4a9b"),
|
||||
];
|
||||
|
||||
export const SWATCH_PSI: [Color, Color] = [
|
||||
Color.parseHexCode("#9cdb43"),
|
||||
Color.parseHexCode("#d6f264"),
|
||||
];
|
||||
|
||||
export const SWATCH_STAT: Record<Stat, [Color, Color]> = {
|
||||
AGI: SWATCH_AGI,
|
||||
INT: SWATCH_INT,
|
||||
CHA: SWATCH_CHA,
|
||||
PSI: SWATCH_PSI,
|
||||
BG0: Color; // UI background -- should be highly readable and similar to SKY or FLOOR
|
||||
FG1: Color; // dark (ex. disabled text)
|
||||
FG2: Color; // normal (ex. normal text)
|
||||
FG3: Color; // brightest (ex. bold text)
|
||||
};
|
||||
|
||||
/*
|
||||
const MICROTHEME_OLD: MicroTheme = {
|
||||
BG0: Color.parseHexCode("#242234"),
|
||||
BG1: Color.parseHexCode("#143464"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
}
|
||||
*/
|
||||
|
||||
export const MICROTHEME_DEFAULT: Microtheme = {
|
||||
// outdoors
|
||||
SKY0: Color.parseHexCode("#242234"),
|
||||
FLOOR0: Color.parseHexCode("#141013"),
|
||||
WALL0: Color.parseHexCode("#5d758d"),
|
||||
WALL1: Color.parseHexCode("#8b93af"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_BLACK: Microtheme = {
|
||||
// manor
|
||||
SKY0: Color.parseHexCode("#141013"),
|
||||
FLOOR0: Color.parseHexCode("#3b1725"),
|
||||
WALL0: Color.parseHexCode("#221c1a"),
|
||||
WALL1: Color.parseHexCode("#322b28"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_PURPLE_TAN: Microtheme = {
|
||||
// library
|
||||
SKY0: Color.parseHexCode("#403353"),
|
||||
FLOOR0: Color.parseHexCode("#242234"),
|
||||
WALL0: Color.parseHexCode("#5a4e44"),
|
||||
WALL1: Color.parseHexCode("#c7b08b"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_GREEN: Microtheme = {
|
||||
// zoo
|
||||
SKY0: Color.parseHexCode("#24523b"),
|
||||
FLOOR0: Color.parseHexCode("#122020"),
|
||||
WALL0: Color.parseHexCode("#328464"),
|
||||
WALL1: Color.parseHexCode("#5daf8d"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_TEAL: Microtheme = {
|
||||
// optometrist
|
||||
SKY0: Color.parseHexCode("#143464"),
|
||||
FLOOR0: Color.parseHexCode("#122020"),
|
||||
WALL0: Color.parseHexCode("#477d85"),
|
||||
WALL1: Color.parseHexCode("#588dbe"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_PINK: Microtheme = {
|
||||
// coffee shop
|
||||
SKY0: Color.parseHexCode("#793a80"),
|
||||
FLOOR0: Color.parseHexCode("#221c1a"),
|
||||
WALL0: Color.parseHexCode("#e86a73"),
|
||||
WALL1: Color.parseHexCode("#f5a097"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_NEON: Microtheme = {
|
||||
// club
|
||||
SKY0: Color.parseHexCode("#141013"),
|
||||
FLOOR0: Color.parseHexCode("#221c1a"),
|
||||
WALL0: Color.parseHexCode("#9cdb43"),
|
||||
WALL1: Color.parseHexCode("#d6f264"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export const MICROTHEME_RED: Microtheme = {
|
||||
// blood bank
|
||||
SKY0: Color.parseHexCode("#73172d"),
|
||||
FLOOR0: Color.parseHexCode("#141013"),
|
||||
WALL0: Color.parseHexCode("#df3e23"),
|
||||
WALL1: Color.parseHexCode("#f9a31b"),
|
||||
BG0: Color.parseHexCode("#000000"),
|
||||
FG1: Color.parseHexCode("#8b93af"),
|
||||
FG2: Color.parseHexCode("#b3b9d1"),
|
||||
FG3: Color.parseHexCode("#ffffff"),
|
||||
};
|
||||
|
||||
export class ColorSystem {
|
||||
get BG_UI() {
|
||||
return this.#microtheme.BG0;
|
||||
}
|
||||
get BG_FLOOR() {
|
||||
return this.#microtheme.FLOOR0;
|
||||
}
|
||||
get FG_TEXT() {
|
||||
return this.#microtheme.FG2;
|
||||
}
|
||||
get FG_TEXT_DISABLED() {
|
||||
return this.#microtheme.FG1;
|
||||
}
|
||||
readonly FG_TOO_EXPENSIVE = Color.parseHexCode("#f5a097");
|
||||
readonly FG_TEXT_ENDORSED = Color.parseHexCode("#d6f264");
|
||||
get FG_BOLD() {
|
||||
return this.#microtheme.FG3;
|
||||
}
|
||||
get BG_OUTER() {
|
||||
return this.#microtheme.SKY0;
|
||||
}
|
||||
get BG_WALL_OR_UNREVEALED() {
|
||||
return this.#microtheme.SKY0;
|
||||
}
|
||||
get BG_CEILING() {
|
||||
return this.#microtheme.SKY0;
|
||||
}
|
||||
|
||||
get BG_INNERWALL() {
|
||||
return this.#microtheme.WALL0;
|
||||
}
|
||||
get BG_OUTERWALL() {
|
||||
return this.#microtheme.WALL1;
|
||||
}
|
||||
|
||||
get #microtheme(): Microtheme {
|
||||
if (getEndgameModal().isShown || getVNModal().isShown) {
|
||||
return MICROTHEME_RED;
|
||||
}
|
||||
let option = maybeGetHuntMode()?.getActiveMicrotheme();
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
return MICROTHEME_DEFAULT;
|
||||
}
|
||||
|
||||
// stat colors
|
||||
readonly SWATCH_EXP: [Color, Color] = [
|
||||
Color.parseHexCode("#b9bffb"),
|
||||
Color.parseHexCode("#e3e6ff"),
|
||||
];
|
||||
|
||||
readonly SWATCH_AGI: [Color, Color] = [
|
||||
Color.parseHexCode("#df3e23"),
|
||||
Color.parseHexCode("#fa6a0a"),
|
||||
];
|
||||
|
||||
readonly SWATCH_INT: [Color, Color] = [
|
||||
Color.parseHexCode("#285cc4"),
|
||||
Color.parseHexCode("#249fde"),
|
||||
];
|
||||
|
||||
readonly SWATCH_CHA: [Color, Color] = [
|
||||
Color.parseHexCode("#793a80"),
|
||||
Color.parseHexCode("#bc4a9b"),
|
||||
];
|
||||
|
||||
readonly SWATCH_PSI: [Color, Color] = [
|
||||
Color.parseHexCode("#9cdb43"),
|
||||
Color.parseHexCode("#d6f264"),
|
||||
];
|
||||
|
||||
readonly SWATCH_STAT: Record<Stat, [Color, Color]> = {
|
||||
AGI: this.SWATCH_AGI,
|
||||
INT: this.SWATCH_INT,
|
||||
CHA: this.SWATCH_CHA,
|
||||
PSI: this.SWATCH_PSI,
|
||||
};
|
||||
}
|
||||
|
||||
export let C = new ColorSystem();
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { VNScene } from "./vnscene.ts";
|
||||
import { Thrall } from "./thralls.ts";
|
||||
|
||||
export type Stat = "AGI" | "INT" | "CHA" | "PSI";
|
||||
export const ALL_STATS: Array<Stat> = ["AGI", "INT", "CHA", "PSI"];
|
||||
@ -104,6 +105,8 @@ export type EndingAnalytics = {
|
||||
export type SuccessorOption = {
|
||||
name: string;
|
||||
title: string;
|
||||
template: Thrall;
|
||||
nImprovements: number;
|
||||
note: string | null; // ex "already a vampire"
|
||||
stats: Record<Stat, number>;
|
||||
talents: Record<Stat, number>;
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { withCamera } from "./layout.ts";
|
||||
import { D } from "./engine/public.ts";
|
||||
import { BG_INSET, FG_BOLD, FG_TEXT } from "./colors.ts";
|
||||
import { AlignX, AlignY, Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { addButton } from "./button.ts";
|
||||
import { ALL_STATS, Ending } from "./datatypes.ts";
|
||||
import { getStateManager } from "./statemanager.ts";
|
||||
import { getWishes } from "./wishes.ts";
|
||||
import { sndEnding } from "./sounds.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
const WIDTH = 384;
|
||||
const HEIGHT = 384;
|
||||
@ -19,6 +20,8 @@ export class EndgameModal {
|
||||
#selectedWish: number | null;
|
||||
#ending: Ending | null;
|
||||
|
||||
#playedSound: boolean;
|
||||
|
||||
constructor() {
|
||||
this.#drawpile = new DrawPile();
|
||||
this.#page = 0;
|
||||
@ -26,8 +29,7 @@ export class EndgameModal {
|
||||
this.#selectedSuccessor = null;
|
||||
this.#selectedWish = null;
|
||||
this.#ending = null;
|
||||
|
||||
// this.show(getScorer().pickEnding());
|
||||
this.#playedSound = false;
|
||||
}
|
||||
|
||||
get isShown(): boolean {
|
||||
@ -39,6 +41,7 @@ export class EndgameModal {
|
||||
this.#selectedSuccessor = null;
|
||||
this.#selectedWish = null;
|
||||
this.#ending = ending;
|
||||
this.#playedSound = false;
|
||||
}
|
||||
|
||||
update() {
|
||||
@ -66,6 +69,11 @@ export class EndgameModal {
|
||||
#update() {
|
||||
this.#fixCompulsory();
|
||||
|
||||
if (!this.#playedSound) {
|
||||
sndEnding.play({ bgm: true });
|
||||
this.#playedSound = true;
|
||||
}
|
||||
|
||||
this.#drawpile.clear();
|
||||
if (this.#page == 0) {
|
||||
let analytics = this.#ending?.analytics;
|
||||
@ -79,22 +87,22 @@ export class EndgameModal {
|
||||
D.drawText(
|
||||
"It is time to announce the sentence of fate.",
|
||||
new Point(0, 0),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
);
|
||||
D.drawText(
|
||||
"You are no longer a fledgling. Your new rank:",
|
||||
new Point(0, 32),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
);
|
||||
D.drawText(rank, new Point(WIDTH / 2, 64), FG_BOLD, {
|
||||
D.drawText(rank, new Point(WIDTH / 2, 64), C.FG_BOLD, {
|
||||
alignX: AlignX.Center,
|
||||
});
|
||||
D.drawText(
|
||||
"You have achieved a DOMICILE STATUS of:",
|
||||
new Point(0, 96),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
);
|
||||
D.drawText(domicile, new Point(WIDTH / 2, 128), FG_BOLD, {
|
||||
D.drawText(domicile, new Point(WIDTH / 2, 128), C.FG_BOLD, {
|
||||
alignX: AlignX.Center,
|
||||
});
|
||||
let whereLabel =
|
||||
@ -103,8 +111,8 @@ export class EndgameModal {
|
||||
: mortalServants >= 1
|
||||
? "where you live with a couple of friends."
|
||||
: "where you live without friends.";
|
||||
D.drawText(whereLabel, new Point(0, 160), FG_TEXT);
|
||||
D.drawText("You have achieved:", new Point(0, 192), FG_TEXT);
|
||||
D.drawText(whereLabel, new Point(0, 160), C.FG_TEXT);
|
||||
D.drawText("You have achieved:", new Point(0, 192), C.FG_TEXT);
|
||||
let itemsPurloinedText =
|
||||
itemsPurloined == 1 ? "item purloined" : "items purloined";
|
||||
let vampiricSkillsText =
|
||||
@ -121,13 +129,13 @@ export class EndgameModal {
|
||||
D.drawText(
|
||||
`${itemsPurloined} ${itemsPurloinedText}\n${vampiricSkills} ${vampiricSkillsText}\n${mortalServants} ${mortalServantsText}`,
|
||||
new Point(WIDTH / 2, 224),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
{ alignX: AlignX.Center },
|
||||
);
|
||||
D.drawText(
|
||||
`${itemsPurloined} ${itemsPurloinedSpcr}\n${vampiricSkills} ${vampiricSkillsSpcr}\n${mortalServants} ${mortalServantsSpcr}`,
|
||||
new Point(WIDTH / 2, 224),
|
||||
FG_BOLD,
|
||||
C.FG_BOLD,
|
||||
{ alignX: AlignX.Center },
|
||||
);
|
||||
let msg = "That's pretty dreadful.";
|
||||
@ -137,14 +145,14 @@ export class EndgameModal {
|
||||
if (mortalServants >= 30) {
|
||||
msg = "That feels like a lot!";
|
||||
}
|
||||
D.drawText(msg, new Point(0, 288), FG_TEXT);
|
||||
D.drawText(msg, new Point(0, 288), C.FG_TEXT);
|
||||
let reignSentence =
|
||||
this.#ending?.personal?.reignSentence ??
|
||||
"Your reign is in an unknown state.";
|
||||
D.drawText(
|
||||
`${reignSentence} It is now time to`,
|
||||
new Point(0, 320),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
{ forceWidth: WIDTH },
|
||||
);
|
||||
});
|
||||
@ -159,7 +167,7 @@ export class EndgameModal {
|
||||
);
|
||||
} else if (this.#page == 1) {
|
||||
this.#drawpile.add(0, () => {
|
||||
D.drawText("Choose your successor:", new Point(0, 0), FG_TEXT);
|
||||
D.drawText("Choose your successor:", new Point(0, 0), C.FG_TEXT);
|
||||
});
|
||||
|
||||
this.#addCandidate(0, new Point(0, 16));
|
||||
@ -174,7 +182,7 @@ export class EndgameModal {
|
||||
D.drawText(
|
||||
`Plan their destiny:${optionalNote}`,
|
||||
new Point(0, 224),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
);
|
||||
});
|
||||
|
||||
@ -283,9 +291,9 @@ export class EndgameModal {
|
||||
this.#drawpile.addClickable(
|
||||
0,
|
||||
(hover) => {
|
||||
let [bg, fg, fgBold] = [BG_INSET, FG_TEXT, FG_BOLD];
|
||||
let [bg, fg, fgBold] = [C.BG_UI, C.FG_TEXT, C.FG_BOLD];
|
||||
if (hover || selected) {
|
||||
[bg, fg, fgBold] = [FG_BOLD, BG_INSET, BG_INSET];
|
||||
[bg, fg, fgBold] = [C.FG_BOLD, C.BG_UI, C.BG_UI];
|
||||
}
|
||||
D.fillRect(at.offset(new Point(0, 4)), new Size(w, h - 8), bg);
|
||||
D.drawRect(at.offset(new Point(0, 4)), new Size(w, h - 8), fg);
|
||||
@ -373,9 +381,9 @@ export class EndgameModal {
|
||||
this.#drawpile.addClickable(
|
||||
0,
|
||||
(hover) => {
|
||||
let [bg, fg, fgBold] = [BG_INSET, FG_TEXT, FG_BOLD];
|
||||
let [bg, fg, fgBold] = [C.BG_UI, C.FG_TEXT, C.FG_BOLD];
|
||||
if (hover || selected) {
|
||||
[bg, fg, fgBold] = [FG_BOLD, BG_INSET, BG_INSET];
|
||||
[bg, fg, fgBold] = [C.FG_BOLD, C.BG_UI, C.BG_UI];
|
||||
}
|
||||
D.fillRect(at.offset(new Point(2, 4)), new Size(w - 4, h - 8), bg);
|
||||
D.drawRect(at.offset(new Point(2, 4)), new Size(w - 4, h - 8), fg);
|
||||
@ -393,7 +401,7 @@ export class EndgameModal {
|
||||
D.drawText(
|
||||
wishData.profile.note,
|
||||
at.offset(new Point(w / 2, h)),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
{
|
||||
alignX: AlignX.Center,
|
||||
},
|
||||
|
@ -1,9 +1,18 @@
|
||||
import { compile, VNScene, VNSceneBasisPart } from "./vnscene.ts";
|
||||
import {
|
||||
sndVnBat,
|
||||
sndVnBreath,
|
||||
sndVnDance,
|
||||
sndVnDoorbell,
|
||||
sndVnGhost,
|
||||
sndVnPage,
|
||||
sndVnPhone,
|
||||
} from "./sounds.ts";
|
||||
|
||||
const squeak: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "squeak.mp3",
|
||||
sfx: sndVnBat,
|
||||
};
|
||||
|
||||
export const sceneBat: VNScene = compile([
|
||||
@ -25,7 +34,7 @@ export const sceneBat: VNScene = compile([
|
||||
const doorbell: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "doorbell.mp3",
|
||||
sfx: sndVnDoorbell,
|
||||
};
|
||||
|
||||
export const sceneStealth: VNScene = compile([
|
||||
@ -33,7 +42,7 @@ export const sceneStealth: VNScene = compile([
|
||||
"Yeah, you can let yourself in.",
|
||||
doorbell,
|
||||
"I'll have it moved.",
|
||||
"Just -- don't call Susan, OK?",
|
||||
"Just -- don't call Liz, OK?",
|
||||
doorbell,
|
||||
"Believe me, I'm good for the money.",
|
||||
"I'm doing... a lot better than it looks like.",
|
||||
@ -46,7 +55,7 @@ export const sceneStealth: VNScene = compile([
|
||||
const phoneBeep: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "phonebeep.mp3",
|
||||
sfx: sndVnPhone,
|
||||
};
|
||||
|
||||
export const sceneCharm: VNScene = compile([
|
||||
@ -61,7 +70,7 @@ export const sceneCharm: VNScene = compile([
|
||||
"Can you put me through?",
|
||||
phoneBeep,
|
||||
"I really want it.",
|
||||
"It's for my boyfriend. First boyfriend, sorry.",
|
||||
"It's for my boyfriend. My old boyfriend, sorry.",
|
||||
phoneBeep,
|
||||
"*chuckle*",
|
||||
"Yeah. I guess I do.",
|
||||
@ -72,7 +81,7 @@ export const sceneCharm: VNScene = compile([
|
||||
const sleepyBreath: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "sleepyBreath.mp3",
|
||||
sfx: sndVnBreath,
|
||||
};
|
||||
|
||||
export const sceneStare: VNScene = compile([
|
||||
@ -93,7 +102,7 @@ export const sceneStare: VNScene = compile([
|
||||
const party: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "party.mp3",
|
||||
sfx: sndVnDance,
|
||||
};
|
||||
|
||||
export const sceneParty: VNScene = compile([
|
||||
@ -111,7 +120,7 @@ export const sceneParty: VNScene = compile([
|
||||
const ghost: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "ghost.mp3",
|
||||
sfx: sndVnGhost,
|
||||
};
|
||||
|
||||
export const sceneLore: VNScene = compile([
|
||||
@ -127,3 +136,37 @@ export const sceneLore: VNScene = compile([
|
||||
"Yeah. They remember.",
|
||||
ghost,
|
||||
]);
|
||||
|
||||
const page: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: sndVnPage,
|
||||
};
|
||||
|
||||
export const sceneTrueEnding: VNScene = compile([
|
||||
page,
|
||||
"(This is a taxonomy. It's what nerds write instead of poetry.)",
|
||||
page,
|
||||
"INSECTIVORE. INSECTIPHAGE. INSECT-EATER. INSECT-EATING INSECT BAT.",
|
||||
"CONSUMER OF BUGS.",
|
||||
"We eat -- flies? They eat beetles.",
|
||||
"We eat -- various bugs. Yes. And we hang in caves --",
|
||||
"We deposit the shells in a heap. An absolutely massive heap --",
|
||||
page,
|
||||
"FRUCTIVORE. FRUITIPHAGE. FRUIT-EATING FRUIT BAT.",
|
||||
"We eat -- grapes, melons, that kind of thing.",
|
||||
"We unearth the heap.",
|
||||
"We lay the shells in trenches in the furrows of a vineyard.",
|
||||
page,
|
||||
"There are two clades and in addition to that is a secret clade.",
|
||||
page,
|
||||
"(The pages are stuck together.)",
|
||||
page,
|
||||
"HEMOPHAGE. HEMOVORE. HEMATOPHAGE. BLOOD EATER.",
|
||||
"It is not yet time to announce the sentence of fate.",
|
||||
"We -- take the wine that grows from the branches.",
|
||||
"That's a simplification.",
|
||||
"This is the night deeper than any night that cannot be spoken of.",
|
||||
page,
|
||||
"OK -- now roll it.",
|
||||
]);
|
||||
|
@ -111,6 +111,15 @@ export class Point {
|
||||
let dy = other.y - this.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
neighbors(): Point[] {
|
||||
return [
|
||||
new Point(this.x, this.y - 1),
|
||||
new Point(this.x - 1, this.y),
|
||||
new Point(this.x, this.y + 1),
|
||||
new Point(this.x + 1, this.y),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class Size {
|
||||
@ -135,6 +144,63 @@ export class Size {
|
||||
}
|
||||
}
|
||||
|
||||
export class Circle {
|
||||
readonly center: Point;
|
||||
readonly radius: number;
|
||||
|
||||
constructor(center: Point, radius: number) {
|
||||
this.center = center;
|
||||
this.radius = radius;
|
||||
}
|
||||
|
||||
getContactWithRect(rect: Rect): Point | null {
|
||||
// port: https://www.jeffreythompson.org/collision-detection/circle-rect.php
|
||||
let cx = this.center.x;
|
||||
let cy = this.center.y;
|
||||
let testX = this.center.x;
|
||||
let testY = this.center.y;
|
||||
|
||||
let rx = rect.top.x;
|
||||
let ry = rect.top.y;
|
||||
let rw = rect.size.w;
|
||||
let rh = rect.size.h;
|
||||
|
||||
if (cx < rx) {
|
||||
testX = rx;
|
||||
} else if (cx > rx + rw) {
|
||||
testX = rx + rw;
|
||||
}
|
||||
if (cy < ry) {
|
||||
testY = ry;
|
||||
} else if (cy > ry + rh) {
|
||||
testY = ry + rh;
|
||||
}
|
||||
|
||||
let distX = cx - testX;
|
||||
let distY = cy - testY;
|
||||
let sqDistance = distX * distX + distY * distY;
|
||||
|
||||
if (sqDistance <= this.radius * this.radius) {
|
||||
return new Point(testX, testY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
overlappedCells(size: Size): Rect[] {
|
||||
let meAsRect = new Rect(
|
||||
this.center.offset(new Point(-this.radius, -this.radius)),
|
||||
new Size(this.radius * 2, this.radius * 2),
|
||||
);
|
||||
let all: Rect[] = [];
|
||||
for (let cell of meAsRect.overlappedCells(size).values()) {
|
||||
if (this.getContactWithRect(cell) != null) {
|
||||
all.push(cell);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
export class Rect {
|
||||
readonly top: Point;
|
||||
readonly size: Size;
|
||||
@ -264,19 +330,29 @@ export class Grid<T> {
|
||||
return new Grid(this.size, (xy) => cbCell(this.get(xy), xy));
|
||||
}
|
||||
|
||||
#checkPosition(position: Point) {
|
||||
if (
|
||||
#invalidPosition(position: Point): boolean {
|
||||
return (
|
||||
position.x < 0 ||
|
||||
position.x >= this.size.w ||
|
||||
Math.floor(position.x) != position.x ||
|
||||
position.y < 0 ||
|
||||
position.y >= this.size.h ||
|
||||
Math.floor(position.y) != position.y
|
||||
) {
|
||||
);
|
||||
}
|
||||
#checkPosition(position: Point) {
|
||||
if (this.#invalidPosition(position)) {
|
||||
throw new Error(`invalid position for ${this.size}: ${position}`);
|
||||
}
|
||||
}
|
||||
|
||||
maybeGet(position: Point): T | null {
|
||||
if (this.#invalidPosition(position)) {
|
||||
return null;
|
||||
}
|
||||
return this.#data[position.y][position.x];
|
||||
}
|
||||
|
||||
get(position: Point): T {
|
||||
this.#checkPosition(position);
|
||||
return this.#data[position.y][position.x];
|
||||
|
@ -111,6 +111,14 @@ class Input {
|
||||
return this.#mousePosition;
|
||||
}
|
||||
|
||||
isAnyKeyDown(...keys: string[]) : boolean {
|
||||
for (const k of keys) {
|
||||
if(this.isKeyDown(k)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
isKeyDown(key: string): boolean {
|
||||
return this.#keyDown[key];
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { BreakableBlockPickupCallbacks } from "./pickups.ts";
|
||||
import { Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { Circle, Point } from "./engine/datatypes.ts";
|
||||
import { displace } from "./physics.ts";
|
||||
import { getHuntMode } from "./huntmode.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { FLOOR_CELL_SIZE } from "./gridart.ts";
|
||||
import { sndCollect } from "./sounds.ts";
|
||||
import { choose } from "./utils.ts";
|
||||
|
||||
export class Floater {
|
||||
xy: Point;
|
||||
@ -23,7 +25,7 @@ export class Floater {
|
||||
this.z = z;
|
||||
this.velZ = 0;
|
||||
|
||||
this.frame = 0;
|
||||
this.frame = choose([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
||||
this.spin = 0;
|
||||
|
||||
this.collected = false;
|
||||
@ -36,7 +38,7 @@ export class Floater {
|
||||
let { displacement, dxy } = displace(
|
||||
bbox,
|
||||
this.velocity,
|
||||
(r) => getHuntMode().isBlocked(r),
|
||||
(r) => getHuntMode().getContact(r),
|
||||
{ bounce: 0.6 },
|
||||
);
|
||||
|
||||
@ -79,6 +81,7 @@ export class Floater {
|
||||
return;
|
||||
}
|
||||
this.collected = true;
|
||||
sndCollect.play({ volume: 0.1 });
|
||||
this.#callbacks.obtain();
|
||||
}
|
||||
|
||||
@ -119,10 +122,9 @@ export class Floater {
|
||||
return !this.collected && this.frame < 1440;
|
||||
}
|
||||
|
||||
get bbox(): Rect {
|
||||
let w = 0.25;
|
||||
let h = 0.25;
|
||||
return new Rect(this.xy.offset(new Point(-w / 2, -h / 2)), new Size(w, h));
|
||||
get bbox(): Circle {
|
||||
let sz = 0.25;
|
||||
return new Circle(this.xy, sz / 2);
|
||||
}
|
||||
drawParticle(projected: Point, isShadow: boolean): any {
|
||||
this.#callbacks.drawParticle(
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { BG_OUTER } from "./colors.ts";
|
||||
import { D, I } from "./engine/public.ts";
|
||||
import { IGame, Point, Size } from "./engine/datatypes.ts";
|
||||
import { getHotbar, Hotbar } from "./hotbar.ts";
|
||||
@ -7,6 +6,7 @@ import { getVNModal, VNModal } from "./vnmodal.ts";
|
||||
import { Gameplay, getGameplay } from "./gameplay.ts";
|
||||
import { getEndgameModal } from "./endgamemodal.ts";
|
||||
import { CheckModal, getCheckModal } from "./checkmodal.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
export class Game implements IGame {
|
||||
#mainThing: Gameplay | VNModal | null;
|
||||
@ -28,7 +28,7 @@ export class Game implements IGame {
|
||||
// draw screen background
|
||||
let oldCamera = D.camera;
|
||||
D.camera = new Point(0, 0);
|
||||
D.fillRect(new Point(0, 0), D.size, BG_OUTER);
|
||||
D.fillRect(new Point(0, 0), D.size, C.BG_OUTER);
|
||||
D.camera = oldCamera;
|
||||
|
||||
this.drawGameplay();
|
||||
|
@ -53,17 +53,43 @@ export class Hotbar {
|
||||
enabled: true,
|
||||
endorse: getPlayerProgress().getBlood() < 100,
|
||||
});
|
||||
/*
|
||||
buttons.push({
|
||||
label:"Cheat",
|
||||
cbClick: () => {
|
||||
new LadderPickup().onClick();
|
||||
},
|
||||
enabled: true,
|
||||
endorse: false,
|
||||
})
|
||||
buttons.push({
|
||||
label:"Dig for bad maps",
|
||||
cbClick: () => {
|
||||
let i = 0;
|
||||
try {
|
||||
for(; i < 10000; i++) {
|
||||
generateMap();
|
||||
}
|
||||
} catch(e) {
|
||||
console.log(`Map gen failed after ${i} tries.`);
|
||||
}
|
||||
console.log("Ten thousand maps generated successfully.");
|
||||
},
|
||||
enabled: true,
|
||||
endorse: true,
|
||||
})
|
||||
*/
|
||||
return buttons;
|
||||
}
|
||||
|
||||
#offerSleep() {
|
||||
let bloodAmount = getPlayerProgress().getBlood();
|
||||
let sleepText = "You're exhausted.";
|
||||
let sleepText = "You're exhausted. Sleep and save your game?";
|
||||
if (bloodAmount > 100) {
|
||||
sleepText =
|
||||
"You've got some energy left -- are you sure you want to sleep?";
|
||||
"You've got some energy left -- are you sure you want to sleep and save your game?";
|
||||
} else if (bloodAmount > 2000) {
|
||||
sleepText = "Are you sure you want to sleep? You have so much energy.";
|
||||
sleepText = "Are you sure you want to sleep and save your game? You have so much energy.";
|
||||
}
|
||||
|
||||
getCheckModal().show(
|
||||
|
38
src/hud.ts
@ -1,17 +1,11 @@
|
||||
import { D } from "./engine/public.ts";
|
||||
import { Point, Size } from "./engine/datatypes.ts";
|
||||
import {
|
||||
BG_OUTER,
|
||||
FG_BOLD,
|
||||
FG_TEXT,
|
||||
FG_TEXT_ENDORSED,
|
||||
FG_TOO_EXPENSIVE,
|
||||
} from "./colors.ts";
|
||||
import { ALL_STATS } from "./datatypes.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { getHuntMode } from "./huntmode.ts";
|
||||
import { getStateManager } from "./statemanager.ts";
|
||||
import { withCamera } from "./layout.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
export class Hud {
|
||||
get size(): Size {
|
||||
@ -33,45 +27,45 @@ export class Hud {
|
||||
#update() {}
|
||||
|
||||
#draw() {
|
||||
D.fillRect(new Point(-4, -4), this.size.add(new Size(8, 8)), BG_OUTER);
|
||||
D.drawText(getPlayerProgress().name, new Point(0, 0), FG_BOLD);
|
||||
D.fillRect(new Point(-4, -4), this.size.add(new Size(8, 8)), C.BG_OUTER);
|
||||
D.drawText(getPlayerProgress().name, new Point(0, 0), C.FG_BOLD);
|
||||
|
||||
let levelText = `Level ${getHuntMode().getDepth()}`;
|
||||
let zoneLabel = getHuntMode().getZoneLabel();
|
||||
if (zoneLabel != null) {
|
||||
levelText += ": " + zoneLabel;
|
||||
}
|
||||
D.drawText(levelText, new Point(0, 16), FG_TEXT);
|
||||
D.drawText(levelText, new Point(0, 16), C.FG_TEXT);
|
||||
D.drawText(
|
||||
`Turn ${getStateManager().getTurn()}/${getStateManager().getMaxTurns()}`,
|
||||
new Point(0, 32),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
);
|
||||
|
||||
let y = 64;
|
||||
let prog = getPlayerProgress();
|
||||
for (let s of ALL_STATS.values()) {
|
||||
D.drawText(`${s}`, new Point(0, y), FG_BOLD);
|
||||
D.drawText(`${prog.getStat(s)}`, new Point(32, y), FG_TEXT);
|
||||
D.drawText(`${s}`, new Point(0, y), C.FG_BOLD);
|
||||
D.drawText(`${prog.getStat(s)}`, new Point(32, y), C.FG_TEXT);
|
||||
let talent = prog.getTalent(s);
|
||||
if (talent > 0) {
|
||||
D.drawText(`(+${talent})`, new Point(56, y), FG_TEXT);
|
||||
D.drawText(`(+${talent})`, new Point(56, y), C.FG_TEXT);
|
||||
}
|
||||
if (talent < 0) {
|
||||
D.drawText(`(${talent})`, new Point(56, y), FG_TEXT);
|
||||
D.drawText(`(${talent})`, new Point(56, y), C.FG_TEXT);
|
||||
}
|
||||
y += 16;
|
||||
}
|
||||
D.drawText("EXP", new Point(0, 144), FG_BOLD);
|
||||
D.drawText(`${prog.getExperience()}`, new Point(32, 144), FG_TEXT);
|
||||
D.drawText("BLD", new Point(0, 160), FG_BOLD);
|
||||
D.drawText("EXP", new Point(0, 144), C.FG_BOLD);
|
||||
D.drawText(`${prog.getExperience()}`, new Point(32, 144), C.FG_TEXT);
|
||||
D.drawText("BLD", new Point(0, 160), C.FG_BOLD);
|
||||
let bloodAmount = prog.getBlood();
|
||||
let bloodColor = FG_TEXT;
|
||||
if (bloodAmount > 2000) {
|
||||
bloodColor = FG_TEXT_ENDORSED;
|
||||
let bloodColor = C.FG_TEXT;
|
||||
if (bloodAmount >= 2000) {
|
||||
bloodColor = C.FG_TEXT_ENDORSED;
|
||||
}
|
||||
if (bloodAmount < 100) {
|
||||
bloodColor = FG_TOO_EXPENSIVE;
|
||||
bloodColor = C.FG_TOO_EXPENSIVE;
|
||||
}
|
||||
D.drawText(`${prog.getBlood()}cc`, new Point(32, 160), bloodColor);
|
||||
}
|
||||
|
@ -1,23 +1,17 @@
|
||||
import { Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { Circle, Point, Size } from "./engine/datatypes.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { D, I } from "./engine/public.ts";
|
||||
import { sprThrallLore } from "./sprites.ts";
|
||||
import {
|
||||
BG_INSET,
|
||||
FG_TEXT,
|
||||
FG_TEXT_ENDORSED,
|
||||
FG_TOO_EXPENSIVE,
|
||||
} from "./colors.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { Architecture, LoadedNewMap } from "./newmap.ts";
|
||||
import { FLOOR_CELL_SIZE, GridArt } from "./gridart.ts";
|
||||
import { shadowcast } from "./shadowcast.ts";
|
||||
import { withCamera } from "./layout.ts";
|
||||
import { getCheckModal } from "./checkmodal.ts";
|
||||
import { CARDINAL_DIRECTIONS } from "./mapgen.ts";
|
||||
import { Block3D, Floor3D, World3D } from "./world3d.ts";
|
||||
import { Floater } from "./floater.ts";
|
||||
import { displace } from "./physics.ts";
|
||||
import { getThralls } from "./thralls.ts";
|
||||
import { C, Microtheme } from "./colors.ts";
|
||||
|
||||
export class HuntMode {
|
||||
map: LoadedNewMap;
|
||||
@ -93,6 +87,10 @@ export class HuntMode {
|
||||
return this.map.get(this.gridifiedPlayer).zoneLabel;
|
||||
}
|
||||
|
||||
getActiveMicrotheme(): Microtheme | null {
|
||||
return this.map.get(this.gridifiedPlayer).microtheme;
|
||||
}
|
||||
|
||||
// draw
|
||||
update() {
|
||||
withCamera("Gameplay", () => {
|
||||
@ -157,19 +155,19 @@ export class HuntMode {
|
||||
|
||||
let mvdx = 0;
|
||||
let mvdy = 0;
|
||||
if (I.isKeyDown("w")) {
|
||||
if (I.isAnyKeyDown("w", "k", "ArrowUp")) {
|
||||
touched = true;
|
||||
mvdy -= amt;
|
||||
}
|
||||
if (I.isKeyDown("s")) {
|
||||
if (I.isAnyKeyDown("s", "j", "ArrowDown")) {
|
||||
touched = true;
|
||||
mvdy += amt;
|
||||
}
|
||||
if (I.isKeyDown("a")) {
|
||||
if (I.isAnyKeyDown("a", "h", "ArrowLeft")) {
|
||||
touched = true;
|
||||
mvdx -= amt;
|
||||
}
|
||||
if (I.isKeyDown("d")) {
|
||||
if (I.isAnyKeyDown("d", "l", "ArrowRight")) {
|
||||
touched = true;
|
||||
mvdx += amt;
|
||||
}
|
||||
@ -196,45 +194,22 @@ export class HuntMode {
|
||||
this.faceLeft = false;
|
||||
}
|
||||
|
||||
let szX = 0.5;
|
||||
let szY = 0.5;
|
||||
let sz = getThralls().get(getPlayerProgress().template).hitboxSize;
|
||||
|
||||
this.velocity = new Point(dx, dy);
|
||||
|
||||
// try to push us away from walls if we're close
|
||||
for (let offset of CARDINAL_DIRECTIONS.values()) {
|
||||
let bigBbox = new Rect(
|
||||
this.floatingPlayer
|
||||
.offset(offset.scale(new Size(0.12, 0.12)))
|
||||
.offset(new Point(-szX / 2, -szY / 2)),
|
||||
new Size(szX, szY),
|
||||
);
|
||||
|
||||
let hitsWall = false;
|
||||
for (let cell of bigBbox.overlappedCells(new Size(1, 1)).values()) {
|
||||
if (this.#blocksMovement(cell.top)) {
|
||||
hitsWall = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hitsWall) {
|
||||
this.velocity = this.velocity.offset(
|
||||
offset.scale(new Point(0.005, 0.005)).negate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let origin = new Point(szX / 2, szY / 2);
|
||||
let bbox = new Rect(
|
||||
this.floatingPlayer.offset(origin.negate()),
|
||||
new Size(szX, szY),
|
||||
);
|
||||
let { displacement, dxy } = displace(bbox, this.velocity, (b: Rect) =>
|
||||
this.isBlocked(b),
|
||||
let bbox = new Circle(this.floatingPlayer, sz / 2);
|
||||
let { displacement, dxy } = displace(bbox, this.velocity, (b: Circle) =>
|
||||
this.getContact(b),
|
||||
);
|
||||
this.floatingPlayer = this.floatingPlayer.offset(displacement);
|
||||
this.velocity = dxy;
|
||||
getPlayerProgress().spendBlood(displacement.distance(new Point(0, 0)) * 10);
|
||||
// let friction do it
|
||||
if (this.map.imposesBloodCosts) {
|
||||
getPlayerProgress().spendBlood(
|
||||
(displacement.distance(new Point(0, 0)) * 10) / 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#updateFov() {
|
||||
@ -324,11 +299,11 @@ export class HuntMode {
|
||||
highlighted = false;
|
||||
}
|
||||
|
||||
let color = BG_INSET;
|
||||
let color = C.BG_FLOOR;
|
||||
if (highlighted) {
|
||||
color = FG_TEXT;
|
||||
color = C.FG_TEXT;
|
||||
if (tooExpensive) {
|
||||
color = FG_TOO_EXPENSIVE;
|
||||
color = C.FG_TOO_EXPENSIVE;
|
||||
}
|
||||
}
|
||||
|
||||
@ -398,8 +373,9 @@ export class HuntMode {
|
||||
});
|
||||
});
|
||||
*/
|
||||
let sprite = getThralls().get(getPlayerProgress().template).sprite;
|
||||
this.drawpile.add(1024, () => {
|
||||
D.drawSprite(sprThrallLore, new Point(192, 192), 1, {
|
||||
D.drawSprite(sprite, new Point(192, 192), 1, {
|
||||
xScale: this.faceLeft ? -2 : 2,
|
||||
yScale: 2,
|
||||
});
|
||||
@ -449,18 +425,18 @@ export class HuntMode {
|
||||
D.fillRect(
|
||||
cellOffset.offset(new Point(-4, -4)),
|
||||
new Size(8, 8),
|
||||
FG_TEXT_ENDORSED,
|
||||
C.FG_TEXT_ENDORSED,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
isBlocked(bbox: Rect): boolean {
|
||||
getContact(bbox: Circle): Point | null {
|
||||
for (let cell of bbox.overlappedCells(new Size(1, 1)).values()) {
|
||||
if (this.#blocksMovement(cell.top)) {
|
||||
return true;
|
||||
return bbox.getContactWithRect(cell)!;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
#blocksMovement(xy: Point) {
|
||||
@ -489,3 +465,7 @@ export function getHuntMode() {
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function maybeGetHuntMode(): HuntMode | null {
|
||||
return active;
|
||||
}
|
||||
|
14
src/main.ts
@ -2,17 +2,5 @@ import { hostGame } from "./engine/internal/host.ts";
|
||||
import { game } from "./game.ts";
|
||||
import { getStateManager } from "./statemanager.ts";
|
||||
|
||||
getStateManager().startGame(
|
||||
{
|
||||
name: "Pyrex",
|
||||
title: "",
|
||||
note: null,
|
||||
stats: { AGI: 10, INT: 10, CHA: 10, PSI: 10 },
|
||||
talents: { AGI: 0, INT: 0, CHA: 0, PSI: 0 },
|
||||
skills: [],
|
||||
isCompulsory: false,
|
||||
inPenance: false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
getStateManager().startOrLoadFirstGame();
|
||||
hostGame(game);
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
ThrallRecruitedPickup,
|
||||
} from "./pickups.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { MICROTHEME_BLACK } from "./colors.ts";
|
||||
|
||||
const BASIC_PLAN = Grid.createGridFromMultilineString(`
|
||||
#####################
|
||||
@ -16,7 +17,7 @@ const BASIC_PLAN = Grid.createGridFromMultilineString(`
|
||||
##### A # # D #####
|
||||
##### ## ## #####
|
||||
# ## ## ## ## #
|
||||
#bB Ee#
|
||||
#bB . Ee#
|
||||
# ## ## ## ## #
|
||||
##### ## ## #####
|
||||
##### C # # F #####
|
||||
@ -27,6 +28,7 @@ const BASIC_PLAN = Grid.createGridFromMultilineString(`
|
||||
|
||||
export function generateManor(): LoadedNewMap {
|
||||
let map = new LoadedNewMap("manor", BASIC_PLAN.size);
|
||||
map.imposesBloodCosts = false;
|
||||
let thralls = getThralls().getAll();
|
||||
|
||||
for (let y = 0; y < BASIC_PLAN.size.h; y++) {
|
||||
@ -52,6 +54,7 @@ export function generateManor(): LoadedNewMap {
|
||||
};
|
||||
|
||||
cell.zoneLabel = "Manor";
|
||||
cell.microtheme = MICROTHEME_BLACK;
|
||||
switch (BASIC_PLAN.get(xy)) {
|
||||
case "#":
|
||||
break;
|
||||
@ -63,6 +66,11 @@ export function generateManor(): LoadedNewMap {
|
||||
cell.architecture = Architecture.Floor;
|
||||
cell.pickup = new LadderPickup();
|
||||
break;
|
||||
case ".":
|
||||
cell.architecture = Architecture.Floor;
|
||||
// TODO: Debug objects can be spawned here
|
||||
// cell.pickup = new ThrallPickup({id: 5});
|
||||
break;
|
||||
case " ":
|
||||
cell.architecture = Architecture.Floor;
|
||||
break;
|
||||
|
187
src/mapgen.ts
@ -14,20 +14,23 @@ import {
|
||||
} from "./pickups.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { ItemStage } from "./thralls.ts";
|
||||
import { Microtheme } from "./colors.ts";
|
||||
|
||||
const WIDTH = 19;
|
||||
const HEIGHT = 19;
|
||||
|
||||
const MIN_VAULTS = 1;
|
||||
const MAX_VAULTS = 1;
|
||||
const MAX_VAULTS = 2;
|
||||
const NUM_VAULT_TRIES = 90;
|
||||
const NUM_ROOM_TRIES = 90;
|
||||
const NUM_STAIRCASE_TRIES = 90;
|
||||
const NUM_STAIRCASES_DESIRED = 3;
|
||||
const NUM_ROOMS_DESIRED = 0; // 4;
|
||||
const NUM_ROOMS_DESIRED = 1;
|
||||
|
||||
const EXTRA_CONNECTOR_CHANCE = 0.15;
|
||||
const WINDING_PERCENT = 0;
|
||||
const WINDING_PERCENT = 50;
|
||||
|
||||
const DEBUG = false;
|
||||
|
||||
// This is an implementation of Nystrom's algorithm:
|
||||
// https://journal.stuffwithstuff.com/2014/12/21/rooms-and-mazes/
|
||||
@ -68,16 +71,22 @@ class Knife {
|
||||
this.#region += 1;
|
||||
}
|
||||
|
||||
carve(point: Point, label?: string) {
|
||||
carve(point: Point, theme?: Microtheme | null, label?: string) {
|
||||
this.#regions.set(point, this.#region);
|
||||
this.map.get(point).architecture = Architecture.Floor;
|
||||
this.map.get(point).microtheme = theme ?? null;
|
||||
this.map.get(point).zoneLabel = label ?? null;
|
||||
}
|
||||
|
||||
carveRoom(room: Rect, protect?: boolean, label?: string) {
|
||||
carveRoom(
|
||||
room: Rect,
|
||||
protect?: boolean,
|
||||
theme?: Microtheme | null,
|
||||
label?: string,
|
||||
) {
|
||||
for (let y = room.top.y; y < room.top.y + room.size.h; y++) {
|
||||
for (let x = room.top.x; x < room.top.x + room.size.w; x++) {
|
||||
this.carve(new Point(x, y), label);
|
||||
this.carve(new Point(x, y), theme, label);
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,6 +98,72 @@ class Knife {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showDebug(merged: Record<number, number>) {
|
||||
if (DEBUG) {
|
||||
let out = "";
|
||||
let errors: string[] = [];
|
||||
const size = this.#regions.size;
|
||||
for (let y = 0; y < size.h; y++) {
|
||||
for (let x = 0; x < size.w; x++) {
|
||||
const loc = new Point(x, y);
|
||||
out += (() => {
|
||||
if (this.#map.get(loc).architecture == Architecture.Wall) {
|
||||
return this.#sealedWalls.get(loc) ? "◘" : "█";
|
||||
}
|
||||
let r = this.#regions.get(loc);
|
||||
if (r !== null) {
|
||||
const resolved = merged[r];
|
||||
if (typeof resolved === "number") {
|
||||
r = resolved;
|
||||
} else {
|
||||
errors.push(`${loc} is region ${r}, not found in merged`);
|
||||
}
|
||||
if (r < 0) {
|
||||
return "!";
|
||||
}
|
||||
// 0...9 and lowercase
|
||||
if (r < 36) {
|
||||
return r.toString(36);
|
||||
}
|
||||
// uppercase
|
||||
r -= 26;
|
||||
if (r < 36) {
|
||||
return r.toString(36).toUpperCase();
|
||||
}
|
||||
// Greek lowercase
|
||||
r -= 36;
|
||||
if (r < 25) {
|
||||
return String.fromCodePoint(r + 0x3b1);
|
||||
}
|
||||
// Greek uppercase (there is a hole at 0x3a2)
|
||||
r -= 25;
|
||||
if (r < 17) {
|
||||
return String.fromCodePoint(r + 0x391);
|
||||
}
|
||||
r -= 17;
|
||||
if (r < 7) {
|
||||
return String.fromCodePoint(r + 0x3a3);
|
||||
}
|
||||
// Hebrew
|
||||
r -= 7;
|
||||
if (r < 27) {
|
||||
return String.fromCodePoint(r + 0x5d0);
|
||||
}
|
||||
// give up
|
||||
return "?";
|
||||
}
|
||||
return "."; // room without region
|
||||
})();
|
||||
}
|
||||
out += "\n";
|
||||
}
|
||||
console.log(out);
|
||||
if (errors.length > 0) {
|
||||
console.log(`uh-oh: \n\t${errors.join("\n\t")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateMap(): LoadedNewMap {
|
||||
@ -99,6 +174,11 @@ export function generateMap(): LoadedNewMap {
|
||||
if (e instanceof TryAgainException) {
|
||||
continue;
|
||||
}
|
||||
if (e instanceof BadMapError) {
|
||||
console.log(`Bad map generated: ${e.message}:`);
|
||||
showDebug(e.badMap);
|
||||
// continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@ -108,7 +188,7 @@ export function tryGenerateMap(vaultTemplates: VaultTemplate[]): LoadedNewMap {
|
||||
let width = WIDTH;
|
||||
let height = HEIGHT;
|
||||
if (width % 2 == 0 || height % 2 == 0) {
|
||||
throw "must be odd-sized";
|
||||
throw new Error("map bounds must be odd-sized");
|
||||
}
|
||||
|
||||
let grid = new LoadedNewMap("generated", new Size(width, height));
|
||||
@ -264,9 +344,24 @@ function carveVault(knife: Knife, room: Rect, vaultTemplate: VaultTemplate) {
|
||||
let ab = mergeRects(a, b);
|
||||
|
||||
knife.startRegion();
|
||||
knife.carveRoom(ab, false, vaultTemplate.roomLabels.hall);
|
||||
knife.carveRoom(c, true, vaultTemplate.roomLabels.backroom);
|
||||
knife.carveRoom(d, true, vaultTemplate.roomLabels.closet);
|
||||
knife.carveRoom(
|
||||
ab,
|
||||
false,
|
||||
vaultTemplate.microtheme(),
|
||||
vaultTemplate.roomLabels.hall,
|
||||
);
|
||||
knife.carveRoom(
|
||||
c,
|
||||
true,
|
||||
vaultTemplate.microtheme(),
|
||||
vaultTemplate.roomLabels.backroom,
|
||||
);
|
||||
knife.carveRoom(
|
||||
d,
|
||||
true,
|
||||
vaultTemplate.microtheme(),
|
||||
vaultTemplate.roomLabels.closet,
|
||||
);
|
||||
|
||||
// now place standard pickups
|
||||
for (let dy = 0; dy < ab.size.h; dy++) {
|
||||
@ -320,7 +415,11 @@ function carveVault(knife: Knife, room: Rect, vaultTemplate: VaultTemplate) {
|
||||
if (check != null) {
|
||||
knife.map.get(connector).pickup = new LockPickup(check);
|
||||
}
|
||||
knife.carve(connector, vaultTemplate.roomLabels.backroom);
|
||||
knife.carve(
|
||||
connector,
|
||||
vaultTemplate.microtheme(),
|
||||
vaultTemplate.roomLabels.backroom,
|
||||
);
|
||||
}
|
||||
if (mergeRects(c, d).contains(connector)) {
|
||||
// TODO: Put check 2 here
|
||||
@ -328,7 +427,11 @@ function carveVault(knife: Knife, room: Rect, vaultTemplate: VaultTemplate) {
|
||||
if (check != null) {
|
||||
knife.map.get(connector).pickup = new LockPickup(check);
|
||||
}
|
||||
knife.carve(connector, vaultTemplate.roomLabels.closet);
|
||||
knife.carve(
|
||||
connector,
|
||||
vaultTemplate.microtheme(),
|
||||
vaultTemplate.roomLabels.closet,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -374,7 +477,7 @@ function carveVault(knife: Knife, room: Rect, vaultTemplate: VaultTemplate) {
|
||||
}
|
||||
|
||||
function carveStaircase(knife: Knife, room: Rect, ix: number) {
|
||||
carveRoom(knife, room, "Stairwell");
|
||||
carveRoom(knife, room, null, "Stairwell");
|
||||
|
||||
let x = Math.floor(room.top.x + room.size.w / 2);
|
||||
let y = Math.floor(room.top.y + room.size.h / 2);
|
||||
@ -389,9 +492,14 @@ function carveStaircase(knife: Knife, room: Rect, ix: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function carveRoom(knife: Knife, room: Rect, label?: string) {
|
||||
function carveRoom(
|
||||
knife: Knife,
|
||||
room: Rect,
|
||||
theme?: Microtheme | null,
|
||||
label?: string,
|
||||
) {
|
||||
knife.startRegion();
|
||||
knife.carveRoom(room, false, label);
|
||||
knife.carveRoom(room, false, theme, label);
|
||||
|
||||
for (let dy = 0; dy < Math.ceil(room.size.h / 2); dy++) {
|
||||
for (let dx = 0; dx < Math.ceil(room.size.w / 2); dx++) {
|
||||
@ -402,18 +510,16 @@ function carveRoom(knife: Knife, room: Rect, label?: string) {
|
||||
new Point(room.size.w - dx - 1, room.size.h - dy - 1),
|
||||
);
|
||||
let stat = choose(ALL_STATS);
|
||||
knife.map.get(xy0).pickup = new BreakableBlockPickup(
|
||||
new StatPickupCallbacks(stat),
|
||||
);
|
||||
knife.map.get(xy1).pickup = new BreakableBlockPickup(
|
||||
new StatPickupCallbacks(stat),
|
||||
);
|
||||
knife.map.get(xy2).pickup = new BreakableBlockPickup(
|
||||
new StatPickupCallbacks(stat),
|
||||
);
|
||||
knife.map.get(xy3).pickup = new BreakableBlockPickup(
|
||||
new StatPickupCallbacks(stat),
|
||||
);
|
||||
let cb = choose([
|
||||
() => new StatPickupCallbacks(stat),
|
||||
() => new StatPickupCallbacks(stat),
|
||||
() => new StatPickupCallbacks(stat),
|
||||
() => new ExperiencePickupCallbacks(),
|
||||
]);
|
||||
knife.map.get(xy0).pickup = new BreakableBlockPickup(cb());
|
||||
knife.map.get(xy1).pickup = new BreakableBlockPickup(cb());
|
||||
knife.map.get(xy2).pickup = new BreakableBlockPickup(cb());
|
||||
knife.map.get(xy3).pickup = new BreakableBlockPickup(cb());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -484,7 +590,7 @@ function connectRegions(knife: Knife) {
|
||||
);
|
||||
}
|
||||
iter++;
|
||||
showDebug(knife.map);
|
||||
knife.showDebug(merged);
|
||||
if (connectors.length == 0) {
|
||||
throw new TryAgainException(
|
||||
"couldn't figure out how to connect sections",
|
||||
@ -498,12 +604,15 @@ function connectRegions(knife: Knife) {
|
||||
let sources: number[] = dedup(basicRegions.map((i) => merged[i]));
|
||||
let dest: number | undefined = sources.pop();
|
||||
if (dest == undefined) {
|
||||
throw "each connector should touch more than one region";
|
||||
throw new BadMapError(
|
||||
`each connector should touch more than one region but ${connector} does not`,
|
||||
knife.map,
|
||||
);
|
||||
}
|
||||
|
||||
if (Math.random() > EXTRA_CONNECTOR_CHANCE) {
|
||||
// at random, don't regard them as merged
|
||||
for (let i = 0; i < knife.region; i++) {
|
||||
for (let i = 0; i <= knife.region; i++) {
|
||||
if (sources.indexOf(merged[i]) != -1) {
|
||||
merged[i] = dest;
|
||||
}
|
||||
@ -532,6 +641,12 @@ function connectRegions(knife: Knife) {
|
||||
}
|
||||
connectors = connectors2;
|
||||
}
|
||||
knife.showDebug(merged);
|
||||
|
||||
// The map should now be fully connected.
|
||||
if (!knife.map.isConnected()) {
|
||||
throw new BadMapError("unconnected", knife.map);
|
||||
}
|
||||
}
|
||||
|
||||
function growMaze(knife: Knife, start: Point) {
|
||||
@ -624,7 +739,7 @@ function decorateRoom(_map: LoadedNewMap, _rect: Rect) {}
|
||||
|
||||
function randrange(lo: number, hi: number) {
|
||||
if (lo >= hi) {
|
||||
throw `randrange: hi must be >= lo, ${hi}, ${lo}`;
|
||||
throw new Error(`randrange: hi must be >= lo, ${hi}, ${lo}`);
|
||||
}
|
||||
|
||||
return lo + Math.floor(Math.random() * (hi - lo));
|
||||
@ -642,7 +757,7 @@ function dedup(items: number[]): number[] {
|
||||
}
|
||||
|
||||
function showDebug(grid: LoadedNewMap) {
|
||||
if (true) {
|
||||
if (DEBUG) {
|
||||
let out = "";
|
||||
for (let y = 0; y < grid.size.h; y++) {
|
||||
for (let x = 0; x < grid.size.w; x++) {
|
||||
@ -658,3 +773,11 @@ function showDebug(grid: LoadedNewMap) {
|
||||
}
|
||||
|
||||
class TryAgainException extends Error {}
|
||||
class BadMapError extends Error {
|
||||
badMap: LoadedNewMap;
|
||||
|
||||
constructor(msg: string, badMap: LoadedNewMap) {
|
||||
super(msg);
|
||||
this.badMap = badMap;
|
||||
}
|
||||
}
|
||||
|
@ -42,14 +42,14 @@ const names = [
|
||||
"Thisby",
|
||||
"Calloway",
|
||||
"Fenna",
|
||||
"Lupin",
|
||||
// "Lupin",
|
||||
"Finlo",
|
||||
"Tycho",
|
||||
"Talmadge",
|
||||
// others
|
||||
"Jeff",
|
||||
"Jon",
|
||||
"Garrett",
|
||||
// "Garrett",
|
||||
"Russell",
|
||||
"Tyson",
|
||||
"Gervase",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Grid, Point, Size } from "./engine/datatypes.ts";
|
||||
import { Pickup } from "./pickups.ts";
|
||||
import { Skill } from "./datatypes.ts";
|
||||
import { Microtheme } from "./colors.ts";
|
||||
|
||||
export enum Architecture {
|
||||
Wall,
|
||||
@ -30,21 +31,25 @@ export class LoadedNewMap {
|
||||
#id: string;
|
||||
#size: Size;
|
||||
#entrance: Point | null;
|
||||
#imposesBloodCosts: boolean;
|
||||
#architecture: Grid<Architecture>;
|
||||
#pickups: Grid<Pickup | null>;
|
||||
#provinces: Grid<string | null>; // TODO: Does this just duplicate zoneLabels
|
||||
#revealed: Grid<boolean>;
|
||||
#zoneLabels: Grid<string | null>;
|
||||
#microthemes: Grid<Microtheme | null>;
|
||||
|
||||
constructor(id: string, size: Size) {
|
||||
this.#id = id;
|
||||
this.#size = size;
|
||||
this.#entrance = null;
|
||||
this.#imposesBloodCosts = true;
|
||||
this.#architecture = new Grid<Architecture>(size, () => Architecture.Wall);
|
||||
this.#pickups = new Grid<Pickup | null>(size, () => null);
|
||||
this.#provinces = new Grid<string | null>(size, () => null);
|
||||
this.#revealed = new Grid<boolean>(size, () => false);
|
||||
this.#zoneLabels = new Grid<string | null>(size, () => null);
|
||||
this.#microthemes = new Grid<Microtheme | null>(size, () => null);
|
||||
}
|
||||
|
||||
set entrance(point: Point) {
|
||||
@ -58,6 +63,14 @@ export class LoadedNewMap {
|
||||
return this.#entrance;
|
||||
}
|
||||
|
||||
set imposesBloodCosts(value: boolean) {
|
||||
this.#imposesBloodCosts = value;
|
||||
}
|
||||
|
||||
get imposesBloodCosts() {
|
||||
return this.#imposesBloodCosts;
|
||||
}
|
||||
|
||||
get size(): Size {
|
||||
return this.#size;
|
||||
}
|
||||
@ -105,6 +118,65 @@ export class LoadedNewMap {
|
||||
getZoneLabel(point: Point): string | null {
|
||||
return this.#zoneLabels.get(point);
|
||||
}
|
||||
|
||||
setMicrotheme(point: Point, value: Microtheme | null) {
|
||||
this.#microthemes.set(point, value);
|
||||
}
|
||||
|
||||
getMicrotheme(point: Point): Microtheme | null {
|
||||
return this.#microthemes.get(point);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
const size = this.#size;
|
||||
let reached = new Grid<boolean>(size, () => false);
|
||||
|
||||
// find starting location
|
||||
const found: Point | null = (() => {
|
||||
for (let x = 0; x < size.w; x++) {
|
||||
for (let y = 0; y < size.w; y++) {
|
||||
const p = new Point(x, y);
|
||||
if (this.#architecture.get(p) == Architecture.Floor) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (found === null) {
|
||||
// technically, all open floors on the map are indeed connected
|
||||
return true;
|
||||
}
|
||||
|
||||
let stack: Point[] = [found];
|
||||
reached.set(found, true);
|
||||
while (stack.length > 0) {
|
||||
const loc = stack.pop() as Point;
|
||||
for (var p of loc.neighbors()) {
|
||||
if (
|
||||
this.#architecture.maybeGet(p) === Architecture.Floor &&
|
||||
!reached.get(p)
|
||||
) {
|
||||
reached.set(p, true);
|
||||
stack.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < size.w; x++) {
|
||||
for (let y = 0; y < size.w; y++) {
|
||||
const p = new Point(x, y);
|
||||
if (
|
||||
this.#architecture.get(p) == Architecture.Floor &&
|
||||
!reached.get(p)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class CellView {
|
||||
@ -155,6 +227,13 @@ export class CellView {
|
||||
return this.#map.getZoneLabel(this.#point);
|
||||
}
|
||||
|
||||
set microtheme(value: Microtheme | null) {
|
||||
this.#map.setMicrotheme(this.#point, value);
|
||||
}
|
||||
get microtheme(): Microtheme | null {
|
||||
return this.#map.getMicrotheme(this.#point);
|
||||
}
|
||||
|
||||
copyFrom(cell: CellView) {
|
||||
this.architecture = cell.architecture;
|
||||
this.pickup = cell.pickup;
|
||||
|
21
src/openingscene.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { compile, VNScene } from "./vnscene.ts";
|
||||
|
||||
export let openingScene: VNScene = compile([
|
||||
`Mortal!
|
||||
|
||||
... I can't call you that anymore, can I?
|
||||
|
||||
Listen up:
|
||||
|
||||
You've been given a gift! Your life is now over. There's no going back... In nine days you will be one of us.
|
||||
|
||||
You probably think this is like a final, but it's more like an entrance exam!
|
||||
|
||||
Soon I will forget about you,
|
||||
|
||||
Your Progenitor
|
||||
|
||||
|
||||
|
||||
PS: Left mouse + WASD. Like Quake! Arrows or HJKL work too.`,
|
||||
]);
|
@ -1,36 +1,48 @@
|
||||
import { Point, Rect } from "./engine/datatypes.ts";
|
||||
import { Circle, lerp, Point } from "./engine/datatypes.ts";
|
||||
|
||||
export function displace(
|
||||
bbox: Rect,
|
||||
bbox: Circle,
|
||||
dxy: Point,
|
||||
blocked: (where: Rect) => boolean,
|
||||
getContact: (where: Circle) => Point | null,
|
||||
options?: { bounce?: number },
|
||||
): { bbox: Rect; displacement: Point; dxy: Point } {
|
||||
): { bbox: Circle; displacement: Point; dxy: Point } {
|
||||
let nSteps = 40;
|
||||
let nRedirections = 40;
|
||||
let bounce = options?.bounce ?? 0;
|
||||
|
||||
let xy = bbox.top;
|
||||
let xy = bbox.center;
|
||||
let redirections = 0;
|
||||
for (let i = 0; i < nSteps; i++) {
|
||||
let trialXy = xy.offset(new Point(dxy.x / nSteps, 0));
|
||||
let trialBbox = new Rect(trialXy, bbox.size);
|
||||
let trialXy = xy.offset(new Point(dxy.x / nSteps, dxy.y / nSteps));
|
||||
let trialBbox = new Circle(trialXy, bbox.radius);
|
||||
|
||||
if (blocked(trialBbox)) {
|
||||
dxy = new Point(bounce * -dxy.x, dxy.y);
|
||||
} else {
|
||||
xy = trialXy;
|
||||
}
|
||||
let contact = getContact(trialBbox);
|
||||
if (contact) {
|
||||
let normal = contact.offset(trialXy.negate());
|
||||
let mag = normal.distance(new Point(0, 0));
|
||||
let nx = mag == 0 ? 0 : normal.x / mag;
|
||||
let ny = mag == 0 ? 0 : normal.y / mag;
|
||||
|
||||
trialXy = xy.offset(new Point(0, dxy.y / nSteps));
|
||||
trialBbox = new Rect(trialXy, bbox.size);
|
||||
if (blocked(trialBbox)) {
|
||||
dxy = new Point(dxy.x, bounce * -dxy.y);
|
||||
let dot = dxy.x * nx + dxy.y * ny;
|
||||
if (redirections < nRedirections) {
|
||||
dxy = new Point(
|
||||
dxy.x - lerp(bounce, 1, 2) * dot * nx,
|
||||
dxy.y - lerp(bounce, 1, 2) * dot * ny,
|
||||
);
|
||||
i -= 1; // try again with reflection
|
||||
redirections += 1;
|
||||
} else {
|
||||
dxy = new Point(0, 0);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
xy = trialXy;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bbox: new Rect(xy, bbox.size),
|
||||
displacement: xy.offset(bbox.top.negate()),
|
||||
bbox: new Circle(xy, bbox.radius),
|
||||
displacement: xy.offset(bbox.center.negate()),
|
||||
dxy,
|
||||
};
|
||||
}
|
||||
|
@ -15,10 +15,18 @@ import { GridArt } from "./gridart.ts";
|
||||
import { getCheckModal } from "./checkmodal.ts";
|
||||
import { Point, Size } from "./engine/datatypes.ts";
|
||||
import { choose } from "./utils.ts";
|
||||
import { FG_BOLD, FG_TEXT, SWATCH_EXP, SWATCH_STAT } from "./colors.ts";
|
||||
import { Block3D } from "./world3d.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { Floater } from "./floater.ts";
|
||||
import {
|
||||
sndBite,
|
||||
sndDeath,
|
||||
sndDig,
|
||||
sndRecruit,
|
||||
sndRewardFor,
|
||||
sndRewardHuge,
|
||||
} from "./sounds.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
export type Pickup =
|
||||
| LockPickup
|
||||
@ -73,7 +81,10 @@ export class LockPickup {
|
||||
update() {}
|
||||
|
||||
onClick(cell: CellView): boolean {
|
||||
getCheckModal().show(this.check, () => (cell.pickup = null));
|
||||
getCheckModal().show(this.check, () => {
|
||||
cell.pickup = null;
|
||||
sndRecruit.play();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -146,6 +157,7 @@ export class BreakableBlockPickup {
|
||||
cellData.pickup = null;
|
||||
|
||||
let n = choose([1, 1, 1, 1, 1, 2, 3]);
|
||||
sndRewardFor(n).play();
|
||||
for (let i = 0; i < n; i++) {
|
||||
let floater = new Floater(
|
||||
cellData.xy.offset(new Point(0.5, 0.5)),
|
||||
@ -171,6 +183,9 @@ export class BreakableBlockPickup {
|
||||
}
|
||||
|
||||
onSqueeze(_cellData: CellView) {
|
||||
if (this.breakProgress == 0) {
|
||||
sndDig.play({ volume: 0.5 });
|
||||
}
|
||||
this.breakProgress = Math.min(
|
||||
this.breakProgress + 0.02 + RECOVERY_PER_TICK,
|
||||
1.0,
|
||||
@ -186,7 +201,7 @@ export class StatPickupCallbacks {
|
||||
}
|
||||
|
||||
get cost(): number {
|
||||
return 100;
|
||||
return 30;
|
||||
}
|
||||
|
||||
obtain() {
|
||||
@ -196,9 +211,9 @@ export class StatPickupCallbacks {
|
||||
|
||||
getBlock(progress: number) {
|
||||
return new Block3D(
|
||||
progress > 0.6 ? FG_BOLD : SWATCH_STAT[this.#stat][1],
|
||||
progress > 0.6 ? FG_TEXT : SWATCH_STAT[this.#stat][0],
|
||||
progress > 0.6 ? FG_BOLD : SWATCH_STAT[this.#stat][1],
|
||||
progress > 0.6 ? C.FG_BOLD : C.SWATCH_STAT[this.#stat][1],
|
||||
progress > 0.6 ? C.FG_TEXT : C.SWATCH_STAT[this.#stat][0],
|
||||
progress > 0.6 ? C.FG_BOLD : C.SWATCH_STAT[this.#stat][1],
|
||||
);
|
||||
}
|
||||
|
||||
@ -233,19 +248,19 @@ export class ExperiencePickupCallbacks {
|
||||
constructor() {}
|
||||
|
||||
get cost(): number {
|
||||
return 100;
|
||||
return 30;
|
||||
}
|
||||
|
||||
obtain() {
|
||||
getPlayerProgress().addExperience(250);
|
||||
getPlayerProgress().addExperience(10);
|
||||
getPlayerProgress().purloinItem();
|
||||
}
|
||||
|
||||
getBlock(progress: number) {
|
||||
return new Block3D(
|
||||
progress > 0.6 ? FG_BOLD : SWATCH_EXP[1],
|
||||
progress > 0.6 ? FG_TEXT : SWATCH_EXP[0],
|
||||
progress > 0.6 ? FG_BOLD : SWATCH_EXP[1],
|
||||
progress > 0.6 ? C.FG_BOLD : C.SWATCH_EXP[1],
|
||||
progress > 0.6 ? C.FG_TEXT : C.SWATCH_EXP[0],
|
||||
progress > 0.6 ? C.FG_BOLD : C.SWATCH_EXP[1],
|
||||
);
|
||||
}
|
||||
|
||||
@ -281,7 +296,7 @@ export class LadderPickup {
|
||||
}
|
||||
|
||||
blocksMovement() {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
obstructsVision() {
|
||||
@ -304,7 +319,9 @@ export class LadderPickup {
|
||||
update() {}
|
||||
|
||||
onClick(): boolean {
|
||||
getPlayerProgress().addBlood(1000);
|
||||
if (getHuntMode().map.imposesBloodCosts) {
|
||||
getPlayerProgress().addBlood(100); // this used to award 1k; 100 now is equivalent to what 300 blood used to be
|
||||
}
|
||||
initHuntMode(new HuntMode(getHuntMode().depth + 1, generateMap()));
|
||||
return false;
|
||||
}
|
||||
@ -358,6 +375,7 @@ export class ThrallPickup {
|
||||
onClick(cell: CellView): boolean {
|
||||
let data = getThralls().get(this.thrall);
|
||||
getCheckModal().show(data.initialCheck, () => {
|
||||
sndRecruit.play();
|
||||
getPlayerProgress().unlockThrall(this.thrall);
|
||||
cell.pickup = null;
|
||||
});
|
||||
@ -520,6 +538,13 @@ export class ThrallRecruitedPickup {
|
||||
100,
|
||||
);
|
||||
getPlayerProgress().damageThrall(this.thrall, choose([0.9]));
|
||||
|
||||
let newLifeStage = getPlayerProgress().getThrallLifeStage(this.thrall);
|
||||
if (lifeStage != LifeStage.Dead && newLifeStage == LifeStage.Dead) {
|
||||
sndDeath.play({ volume: 1.0 });
|
||||
} else {
|
||||
sndBite.play({ volume: 1.0 });
|
||||
}
|
||||
},
|
||||
);
|
||||
return true;
|
||||
@ -583,7 +608,7 @@ export class ThrallCollectionPlatePickup {
|
||||
D.drawRect(
|
||||
gridArt.project(0).offset(new Point(-18, -18)),
|
||||
new Size(36, 36),
|
||||
FG_TEXT,
|
||||
C.FG_TEXT,
|
||||
);
|
||||
} else {
|
||||
D.drawSprite(data.sprite, gridArt.project(2), 3, {
|
||||
@ -651,6 +676,7 @@ export class ThrallCollectionPlatePickup {
|
||||
null,
|
||||
);
|
||||
data.rewardCallback((what) => this.spawn(cell.xy, what));
|
||||
sndRewardHuge.play();
|
||||
}
|
||||
}
|
||||
|
||||
@ -665,12 +691,10 @@ export class ThrallCollectionPlatePickup {
|
||||
} else {
|
||||
callbacks = new StatPickupCallbacks(what);
|
||||
}
|
||||
if (callbacks == null) { return; }
|
||||
let floater = new Floater(
|
||||
xy.offset(new Point(0.5, 0.5)),
|
||||
50,
|
||||
callbacks,
|
||||
);
|
||||
if (callbacks == null) {
|
||||
return;
|
||||
}
|
||||
let floater = new Floater(xy.offset(new Point(0.5, 0.5)), 50, callbacks);
|
||||
let speed = 0.015;
|
||||
let direction = Math.random() * Math.PI * 2;
|
||||
floater.velocity = new Point(
|
||||
|
@ -1,9 +1,17 @@
|
||||
import { ALL_STATS, Skill, Stat, SuccessorOption, Wish } from "./datatypes.ts";
|
||||
import { getSkills } from "./skills.ts";
|
||||
import { getThralls, ItemStage, LifeStage, Thrall } from "./thralls.ts";
|
||||
import { SaveFileV1, mustBeSaveFileV1 } from "./saveformat.ts";
|
||||
|
||||
interface NewRoundConfig {
|
||||
asSuccessor: SuccessorOption;
|
||||
withWish: Wish | null;
|
||||
}
|
||||
|
||||
export class PlayerProgress {
|
||||
#name: string;
|
||||
#thrallTemplate: number;
|
||||
#nImprovements: number;
|
||||
#stats: Record<Stat, number>;
|
||||
#talents: Record<Stat, number>;
|
||||
#isInPenance: boolean;
|
||||
@ -18,23 +26,65 @@ export class PlayerProgress {
|
||||
#thrallsObtainedItem: number[];
|
||||
#thrallsDeliveredItem: number[];
|
||||
|
||||
constructor(asSuccessor: SuccessorOption, withWish: Wish | null) {
|
||||
this.#name = asSuccessor.name;
|
||||
this.#stats = { ...asSuccessor.stats };
|
||||
this.#talents = { ...asSuccessor.talents };
|
||||
this.#isInPenance = asSuccessor.inPenance;
|
||||
this.#wish = withWish;
|
||||
this.#exp = 0;
|
||||
this.#blood = 0;
|
||||
this.#itemsPurloined = 0;
|
||||
this.#skillsLearned = [];
|
||||
this.#untrimmedSkillsAvailable = [];
|
||||
this.#thrallsUnlocked = [];
|
||||
this.#thrallDamage = {};
|
||||
this.#thrallsObtainedItem = [];
|
||||
this.#thrallsDeliveredItem = [];
|
||||
constructor(args: NewRoundConfig | SaveFileV1) {
|
||||
if ("asSuccessor" in args) {
|
||||
//asSuccessor: SuccessorOption, withWish: Wish | null) {
|
||||
const config = args as NewRoundConfig;
|
||||
const asSuccessor = config.asSuccessor;
|
||||
this.#name = asSuccessor.name;
|
||||
this.#thrallTemplate = asSuccessor.template.id;
|
||||
this.#nImprovements = asSuccessor.nImprovements;
|
||||
this.#stats = { ...asSuccessor.stats };
|
||||
this.#talents = { ...asSuccessor.talents };
|
||||
this.#isInPenance = asSuccessor.inPenance;
|
||||
this.#wish = config.withWish;
|
||||
this.#exp = 0;
|
||||
this.#blood = 0;
|
||||
this.#itemsPurloined = 0;
|
||||
this.#skillsLearned = [];
|
||||
this.#untrimmedSkillsAvailable = [];
|
||||
this.#thrallsUnlocked = [];
|
||||
this.#thrallDamage = {};
|
||||
this.#thrallsObtainedItem = [];
|
||||
this.#thrallsDeliveredItem = [];
|
||||
|
||||
this.refill();
|
||||
this.refill();
|
||||
} else {
|
||||
const file = mustBeSaveFileV1(args);
|
||||
this.#name = file.name;
|
||||
this.#thrallTemplate = file.thrallTemplateId;
|
||||
this.#nImprovements = file.nImprovements;
|
||||
this.#stats = {
|
||||
AGI: file.stats.agi,
|
||||
INT: file.stats.int,
|
||||
CHA: file.stats.cha,
|
||||
PSI: file.stats.psi,
|
||||
};
|
||||
this.#talents = {
|
||||
AGI: file.talents.agi,
|
||||
INT: file.talents.int,
|
||||
CHA: file.talents.cha,
|
||||
PSI: file.talents.psi,
|
||||
};
|
||||
(this.#isInPenance = file.isInPenance),
|
||||
(this.#wish = file.wishId >= 0 ? { id: file.wishId } : null);
|
||||
this.#exp = file.exp;
|
||||
this.#blood = file.blood;
|
||||
this.#itemsPurloined = file.itemsPurloined;
|
||||
this.#skillsLearned = file.skillsLearned;
|
||||
this.#untrimmedSkillsAvailable = file.untrimmedSkillsAvailableIds.map(
|
||||
(id) => {
|
||||
return { id: id };
|
||||
},
|
||||
);
|
||||
this.#thrallsUnlocked = file.thrallsUnlocked;
|
||||
this.#thrallDamage = {};
|
||||
for (let i = 0; i < file.thrallDamage.length; ++i) {
|
||||
this.#thrallDamage[i] = file.thrallDamage[i];
|
||||
}
|
||||
this.#thrallsObtainedItem = file.thrallsObtainedItem;
|
||||
this.#thrallsDeliveredItem = file.thrallsDeliveredItem;
|
||||
}
|
||||
}
|
||||
|
||||
applyEndOfTurn() {
|
||||
@ -47,12 +97,20 @@ export class PlayerProgress {
|
||||
return this.#name;
|
||||
}
|
||||
|
||||
get template(): Thrall {
|
||||
return { id: this.#thrallTemplate };
|
||||
}
|
||||
|
||||
get nImprovements(): number {
|
||||
return this.#nImprovements;
|
||||
}
|
||||
|
||||
get isInPenance(): boolean {
|
||||
return this.#isInPenance;
|
||||
}
|
||||
|
||||
refill() {
|
||||
this.#blood = 2000;
|
||||
this.#blood = 1000;
|
||||
|
||||
let learnableSkills = []; // TODO: Also include costing info
|
||||
for (let skill of getSkills()
|
||||
@ -194,7 +252,11 @@ export class PlayerProgress {
|
||||
return skillsAvailable.slice(0, 6);
|
||||
}
|
||||
|
||||
getLearnedSkills() {
|
||||
getUntrimmedAvailableSkillIds(): number[] {
|
||||
return this.#untrimmedSkillsAvailable.map((s) => s.id);
|
||||
}
|
||||
|
||||
getLearnedSkills(): Skill[] {
|
||||
let learnedSkills = [];
|
||||
for (let s of this.#skillsLearned.values()) {
|
||||
learnedSkills.push({ id: s });
|
||||
@ -202,6 +264,10 @@ export class PlayerProgress {
|
||||
return learnedSkills;
|
||||
}
|
||||
|
||||
getRawLearnedSkills(): number[] {
|
||||
return [...this.#skillsLearned];
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return { ...this.#stats };
|
||||
}
|
||||
@ -221,6 +287,10 @@ export class PlayerProgress {
|
||||
return this.#thrallsUnlocked.indexOf(thrall.id) != -1;
|
||||
}
|
||||
|
||||
getUnlockedThrallIds(): number[] {
|
||||
return [...this.#thrallsUnlocked];
|
||||
}
|
||||
|
||||
damageThrall(thrall: Thrall, amount: number) {
|
||||
if (amount <= 0.0) {
|
||||
throw new Error(`damage must be some positive amount, not ${amount}`);
|
||||
@ -234,6 +304,10 @@ export class PlayerProgress {
|
||||
(this.#thrallDamage[thrall.id] ?? 0.0) + amount;
|
||||
}
|
||||
|
||||
getThrallDamage(thrall: Thrall): number {
|
||||
return this.#thrallDamage[thrall.id] ?? 0.0;
|
||||
}
|
||||
|
||||
getThrallLifeStage(thrall: Thrall): LifeStage {
|
||||
let damage = this.#thrallDamage[thrall.id] ?? 0;
|
||||
if (damage < 0.5) {
|
||||
@ -258,6 +332,10 @@ export class PlayerProgress {
|
||||
this.#thrallsObtainedItem.push(thrall.id);
|
||||
}
|
||||
|
||||
getThrallObtainedItemIds(): number[] {
|
||||
return [...this.#thrallsObtainedItem];
|
||||
}
|
||||
|
||||
deliverThrallItem(thrall: Thrall) {
|
||||
if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) {
|
||||
return;
|
||||
@ -265,6 +343,10 @@ export class PlayerProgress {
|
||||
this.#thrallsDeliveredItem.push(thrall.id);
|
||||
}
|
||||
|
||||
getThrallDeliveredItemIds(): number[] {
|
||||
return [...this.#thrallsDeliveredItem];
|
||||
}
|
||||
|
||||
getThrallItemStage(thrall: Thrall): ItemStage {
|
||||
if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) {
|
||||
return ItemStage.Delivered;
|
||||
@ -296,7 +378,11 @@ export function initPlayerProgress(
|
||||
asSuccessor: SuccessorOption,
|
||||
withWish: Wish | null,
|
||||
) {
|
||||
active = new PlayerProgress(asSuccessor, withWish);
|
||||
active = new PlayerProgress({ asSuccessor: asSuccessor, withWish: withWish });
|
||||
}
|
||||
|
||||
export function rehydratePlayerProgress(savefile: SaveFileV1) {
|
||||
active = new PlayerProgress(savefile);
|
||||
}
|
||||
|
||||
export function getPlayerProgress(): PlayerProgress {
|
||||
|
147
src/save.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { getPlayerProgress } from "./playerprogress";
|
||||
import { getStateManager } from "./statemanager";
|
||||
import { getThralls } from "./thralls";
|
||||
import { SaveFileV1, mustBeSaveFileV1 } from "./saveformat";
|
||||
import { getHuntMode } from "./huntmode.ts";
|
||||
|
||||
export interface SaveFile {
|
||||
version: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
type SaveSlot = "FLEDGLING_SLOT_1" | "FLEDGLING_SLOT_2";
|
||||
|
||||
/// The result of attempting to load a V1 save file.
|
||||
interface SaveFileV1LoadResult {
|
||||
// If present and valid, the loaded file.
|
||||
file: SaveFileV1 | null;
|
||||
|
||||
/// A file loading error, if any. If `file` is present, this refers
|
||||
/// to an error reading from the *other* slot.
|
||||
error: string | null;
|
||||
|
||||
/// The slot this file was loaded from, or that a load attempt failed from.
|
||||
/// If multiple load attempts failed and none succeeded, this refers to
|
||||
/// any one attempted slot.
|
||||
slot: SaveSlot;
|
||||
}
|
||||
|
||||
function readFromSlot(slot: SaveSlot): SaveFileV1LoadResult {
|
||||
var serialized = localStorage.getItem(slot);
|
||||
if (serialized === null) {
|
||||
return {
|
||||
file: null,
|
||||
error: null,
|
||||
slot: slot,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
file: mustBeSaveFileV1(JSON.parse(serialized)),
|
||||
error: null,
|
||||
slot: slot,
|
||||
};
|
||||
} catch (e) {
|
||||
let message = "unidentifiable error";
|
||||
if (e instanceof Error) {
|
||||
message = e.message;
|
||||
}
|
||||
return {
|
||||
file: null,
|
||||
error: message,
|
||||
slot: slot,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the more recent valid save file, if either is valid. If no save files
|
||||
/// are present, `file` and `error` will both be absent. If an invalid save
|
||||
/// file is discovered, `error` will refer to the issue(s) detected.
|
||||
export function readBestSave(): SaveFileV1LoadResult {
|
||||
const from1 = readFromSlot("FLEDGLING_SLOT_1");
|
||||
const from2 = readFromSlot("FLEDGLING_SLOT_2");
|
||||
if (from1.file && from2.file) {
|
||||
return from1.file.revision > from2.file.revision ? from1 : from2;
|
||||
}
|
||||
|
||||
var errors: string[] = [];
|
||||
if (from1.error) {
|
||||
errors = ["slot 1 error: " + from1.error];
|
||||
}
|
||||
if (from2.error) {
|
||||
errors.push("slot 2 error: " + from2.error);
|
||||
}
|
||||
var msg: string | null = errors.length > 0 ? errors.join("\n") : null;
|
||||
if (from1.file) {
|
||||
return {
|
||||
file: from1.file,
|
||||
error: msg,
|
||||
slot: "FLEDGLING_SLOT_1",
|
||||
};
|
||||
}
|
||||
return {
|
||||
file: from2.file,
|
||||
error: msg,
|
||||
slot: "FLEDGLING_SLOT_2",
|
||||
};
|
||||
}
|
||||
|
||||
export function saveGame() {
|
||||
const targetSlot: SaveSlot =
|
||||
readBestSave().slot === "FLEDGLING_SLOT_1"
|
||||
? "FLEDGLING_SLOT_2"
|
||||
: "FLEDGLING_SLOT_1";
|
||||
return saveIntoSlot(targetSlot);
|
||||
}
|
||||
|
||||
function extractCurrentState(): SaveFileV1 {
|
||||
const progress = getPlayerProgress();
|
||||
const stateManager = getStateManager();
|
||||
const huntMode = getHuntMode();
|
||||
var thrallDamage: number[] = [];
|
||||
const nThralls = getThralls().length;
|
||||
for (let i = 0; i < nThralls; ++i) {
|
||||
thrallDamage.push(progress.getThrallDamage({ id: i }));
|
||||
}
|
||||
return {
|
||||
version: "fledgling_save_v1",
|
||||
revision: stateManager.nextRevision(),
|
||||
turn: stateManager.getTurn(),
|
||||
name: progress.name,
|
||||
thrallTemplateId: progress.template.id,
|
||||
nImprovements: progress.nImprovements,
|
||||
stats: {
|
||||
agi: progress.getStat("AGI"),
|
||||
int: progress.getStat("INT"),
|
||||
cha: progress.getStat("CHA"),
|
||||
psi: progress.getStat("PSI"),
|
||||
},
|
||||
talents: {
|
||||
agi: progress.getTalent("AGI"),
|
||||
int: progress.getTalent("INT"),
|
||||
cha: progress.getTalent("CHA"),
|
||||
psi: progress.getTalent("PSI"),
|
||||
},
|
||||
isInPenance: progress.isInPenance,
|
||||
wishId: progress.getWish()?.id ?? -1,
|
||||
exp: progress.getExperience(),
|
||||
blood: progress.getBlood(),
|
||||
itemsPurloined: progress.getItemsPurloined(),
|
||||
skillsLearned: progress.getRawLearnedSkills(),
|
||||
untrimmedSkillsAvailableIds: progress.getUntrimmedAvailableSkillIds(),
|
||||
thrallsUnlocked: progress.getUnlockedThrallIds(),
|
||||
thrallDamage: thrallDamage,
|
||||
thrallsObtainedItem: progress.getThrallObtainedItemIds(),
|
||||
thrallsDeliveredItem: progress.getThrallDeliveredItemIds(),
|
||||
depth: huntMode.getDepth(),
|
||||
};
|
||||
}
|
||||
|
||||
function saveIntoSlot(slot: SaveSlot) {
|
||||
localStorage.setItem(slot, JSON.stringify(extractCurrentState()));
|
||||
}
|
||||
|
||||
export function wipeSaves() {
|
||||
localStorage.removeItem("FLEDGLING_SLOT_1");
|
||||
localStorage.removeItem("FLEDGLING_SLOT_2");
|
||||
}
|
185
src/saveformat.ts
Normal file
@ -0,0 +1,185 @@
|
||||
export interface StatCounterV1 {
|
||||
agi: number;
|
||||
int: number;
|
||||
cha: number;
|
||||
psi: number;
|
||||
}
|
||||
|
||||
export interface SaveFileV1 {
|
||||
version: "fledgling_save_v1";
|
||||
revision: number;
|
||||
|
||||
turn: number;
|
||||
|
||||
name: string;
|
||||
thrallTemplateId: number;
|
||||
nImprovements: number;
|
||||
stats: StatCounterV1;
|
||||
talents: StatCounterV1;
|
||||
isInPenance: boolean;
|
||||
wishId: number; // negative: Wish is absent
|
||||
exp: number;
|
||||
blood: number;
|
||||
itemsPurloined: number;
|
||||
skillsLearned: number[];
|
||||
untrimmedSkillsAvailableIds: number[];
|
||||
thrallsUnlocked: number[];
|
||||
thrallDamage: number[]; // 0: thrall is absent or undamaged
|
||||
thrallsObtainedItem: number[];
|
||||
thrallsDeliveredItem: number[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/// Checks whether obj is a valid save file, as far as we can tell, and returns
|
||||
/// it unchanged if it is, or throws an error if it's not valid.
|
||||
export function mustBeSaveFileV1(obj: unknown): SaveFileV1 {
|
||||
if (obj === undefined || obj === null) {
|
||||
throw new Error("nonexistent");
|
||||
}
|
||||
if (typeof obj !== "object") {
|
||||
throw new Error(`not an object; was ${typeof obj}`);
|
||||
}
|
||||
|
||||
if (!("version" in obj)) {
|
||||
throw new Error("no magic number");
|
||||
}
|
||||
if (obj.version !== "fledgling_save_v1") {
|
||||
throw new Error(`bad magic number: ${obj.version}`);
|
||||
}
|
||||
|
||||
return {
|
||||
version: "fledgling_save_v1",
|
||||
revision: mustGetNumber(obj, "revision"),
|
||||
turn: mustGetNumber(obj, "turn"),
|
||||
name: mustGetString(obj, "name"),
|
||||
thrallTemplateId: mustGetNumber(obj, "thrallTemplateId"),
|
||||
nImprovements: mustGetNumber(obj, "nImprovements"),
|
||||
stats: mustGetStatCounterV1(obj, "stats"),
|
||||
talents: mustGetStatCounterV1(obj, "talents"),
|
||||
isInPenance: mustGetBoolean(obj, "isInPenance"),
|
||||
wishId: mustGetNumber(obj, "wishId"),
|
||||
exp: mustGetNumber(obj, "exp"),
|
||||
blood: mustGetNumber(obj, "blood"),
|
||||
itemsPurloined: mustGetNumber(obj, "itemsPurloined"),
|
||||
skillsLearned: mustGetNumberArray(obj, "skillsLearned"),
|
||||
untrimmedSkillsAvailableIds: mustGetNumberArray(
|
||||
obj,
|
||||
"untrimmedSkillsAvailableIds",
|
||||
),
|
||||
thrallsUnlocked: mustGetNumberArray(obj, "thrallsUnlocked"),
|
||||
thrallDamage: mustGetNumberArray(obj, "thrallDamage"),
|
||||
thrallsObtainedItem: mustGetNumberArray(obj, "thrallsObtainedItem"),
|
||||
thrallsDeliveredItem: mustGetNumberArray(obj, "thrallsDeliveredItem"),
|
||||
depth: mustGetNumber(obj, "depth"),
|
||||
};
|
||||
}
|
||||
|
||||
function mustGetNumber(obj: object, key: string): number {
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new Error("container absent");
|
||||
}
|
||||
if (typeof obj !== "object") {
|
||||
throw new Error(`container was not an object; was ${typeof obj}`);
|
||||
}
|
||||
if (!(key in obj)) {
|
||||
throw new Error(`missing number: ${key}`);
|
||||
}
|
||||
const dict = obj as { [key: string]: any };
|
||||
const val = dict[key];
|
||||
if (typeof val !== "number") {
|
||||
throw new Error(`not a number: ${key}: ${val}`);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function mustGetString(obj: object, key: string): string {
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new Error("container absent");
|
||||
}
|
||||
if (typeof obj !== "object") {
|
||||
throw new Error(`container was not an object; was ${typeof obj}`);
|
||||
}
|
||||
if (!(key in obj)) {
|
||||
throw new Error(`missing number: ${key}`);
|
||||
}
|
||||
const dict = obj as { [key: string]: any };
|
||||
const val = dict[key];
|
||||
if (typeof val !== "string") {
|
||||
throw new Error(`not a string: ${key}: ${val}`);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function mustGetStatCounterV1(obj: object, key: string): StatCounterV1 {
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new Error("container absent");
|
||||
}
|
||||
if (typeof obj !== "object") {
|
||||
throw new Error(`container was not an object; was ${typeof obj}`);
|
||||
}
|
||||
if (!(key in obj)) {
|
||||
throw new Error(`missing number: ${key}`);
|
||||
}
|
||||
const dict = obj as { [key: string]: any };
|
||||
const val = dict[key];
|
||||
if (typeof val !== "object") {
|
||||
throw new Error(`not an object: ${key}: ${val}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
agi: mustGetNumber(val, "agi"),
|
||||
int: mustGetNumber(val, "int"),
|
||||
cha: mustGetNumber(val, "cha"),
|
||||
psi: mustGetNumber(val, "psi"),
|
||||
};
|
||||
} catch (e) {
|
||||
let message = "unrecognizable error";
|
||||
if (e instanceof Error) {
|
||||
message = e.message;
|
||||
}
|
||||
throw new Error(`reading ${key}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mustGetBoolean(obj: object, key: string): boolean {
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new Error("container absent");
|
||||
}
|
||||
if (typeof obj !== "object") {
|
||||
throw new Error(`container was not an object; was ${typeof obj}`);
|
||||
}
|
||||
if (!(key in obj)) {
|
||||
throw new Error(`missing number: ${key}`);
|
||||
}
|
||||
const dict = obj as { [key: string]: any };
|
||||
const val = dict[key];
|
||||
if (typeof val !== "boolean") {
|
||||
throw new Error(`not boolean: ${key}: ${val}`);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function mustGetNumberArray(obj: object, key: string): number[] {
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new Error("container absent");
|
||||
}
|
||||
if (typeof obj !== "object") {
|
||||
throw new Error(`container was not an object; was ${typeof obj}`);
|
||||
}
|
||||
if (!(key in obj)) {
|
||||
throw new Error(`missing number: ${key}`);
|
||||
}
|
||||
const dict = obj as { [key: string]: any };
|
||||
const val = dict[key];
|
||||
if (typeof val !== "object") {
|
||||
throw new Error(`not an object: ${key}: ${val}`);
|
||||
}
|
||||
|
||||
for (const x of val) {
|
||||
if (typeof x !== "number") {
|
||||
throw new Error(`contained non-number item in ${key}: ${val}`);
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}
|
@ -9,6 +9,7 @@ import {
|
||||
sceneParty,
|
||||
sceneStare,
|
||||
sceneStealth,
|
||||
sceneTrueEnding,
|
||||
} from "./endings.ts";
|
||||
import { generateWishes, getWishes, isWishCompleted } from "./wishes.ts";
|
||||
import { generateSuccessors } from "./successors.ts";
|
||||
@ -81,7 +82,12 @@ class Scorer {
|
||||
// TODO: Award different ranks depending on second-to-top skill
|
||||
// TODO: Award different domiciles based on overall score
|
||||
// TODO: Force the rank to match the wish if one existed
|
||||
else if (isMax("stare", 3)) {
|
||||
else if (vampiricSkills >= 24) {
|
||||
scene = sceneTrueEnding;
|
||||
rank = "Master Vampire";
|
||||
domicile = "Third Clade";
|
||||
reignSentence = "You know the truth, or at least your character does.";
|
||||
} else if (isMax("stare", 3)) {
|
||||
scene = sceneStare;
|
||||
rank = "Hypno-Chiropteran";
|
||||
domicile = "Village of Brainwashed Mortals";
|
||||
@ -122,7 +128,10 @@ class Scorer {
|
||||
vampiricSkills,
|
||||
mortalServants,
|
||||
};
|
||||
let successorOptions = generateSuccessors(0, penance); // TODO: generate nImprovements from mortalServants and the player's bsae improvements
|
||||
let successorOptions = generateSuccessors(
|
||||
getPlayerProgress().nImprovements + 2,
|
||||
penance,
|
||||
); // TODO: generate nImprovements from mortalServants and the player's bsae improvements
|
||||
let wishOptions = generateWishes(penance);
|
||||
|
||||
let progenerateVerb = penance ? "Repent" : "Progenerate";
|
||||
|
@ -38,16 +38,23 @@ class SkillsTable {
|
||||
}
|
||||
|
||||
computeCost(skill: Skill) {
|
||||
const _STAT_TO_TRIPS: Record<Stat, number> = {
|
||||
AGI: 1 / 7.2, // 8.4 is what I measured, but this seems very overpriced in practice
|
||||
INT: 1 / 5.4,
|
||||
CHA: 1 / 4.8,
|
||||
PSI: 1 / 7.0,
|
||||
};
|
||||
let data = this.get(skill);
|
||||
|
||||
let governingStatValue = 0;
|
||||
for (let stat of data.governing.stats.values()) {
|
||||
governingStatValue +=
|
||||
getPlayerProgress().getStat(stat) / data.governing.stats.length;
|
||||
(getPlayerProgress().getStat(stat) * _STAT_TO_TRIPS[stat]) /
|
||||
data.governing.stats.length;
|
||||
}
|
||||
|
||||
if (data.governing.flipped) {
|
||||
governingStatValue = -governingStatValue + 10;
|
||||
governingStatValue = -governingStatValue + 1;
|
||||
}
|
||||
|
||||
let mult = getCostMultiplier(getPlayerProgress().getWish(), skill);
|
||||
@ -94,7 +101,7 @@ function geomInterpolate(
|
||||
return lowOut * Math.pow(highOut / lowOut, proportion);
|
||||
}
|
||||
|
||||
type Difficulty = 0 | 1 | 1.25 | 2 | 3;
|
||||
type Difficulty = -0.25 | -0.125 | 0 | 1 | 1.25 | 2 | 3;
|
||||
type GoverningTemplate = {
|
||||
stats: Stat[];
|
||||
note: string;
|
||||
@ -158,34 +165,46 @@ function governing(
|
||||
let cost: number;
|
||||
let mortalServantValue: number;
|
||||
switch (difficulty) {
|
||||
case -0.25:
|
||||
underTarget = 0.0;
|
||||
target = 3.9;
|
||||
cost = 50;
|
||||
mortalServantValue = 1;
|
||||
break;
|
||||
case -0.125:
|
||||
underTarget = 0.25;
|
||||
target = 4.25;
|
||||
cost = 50;
|
||||
mortalServantValue = 1;
|
||||
break;
|
||||
case 0:
|
||||
underTarget = 5;
|
||||
target = 15;
|
||||
underTarget = 0.5;
|
||||
target = 4.5;
|
||||
cost = 50;
|
||||
mortalServantValue = 1;
|
||||
break;
|
||||
case 1:
|
||||
underTarget = 15;
|
||||
target = 40;
|
||||
cost = 100;
|
||||
underTarget = 4;
|
||||
target = 10;
|
||||
cost = 50;
|
||||
mortalServantValue = 2;
|
||||
break;
|
||||
case 1.25:
|
||||
underTarget = 17;
|
||||
target = 42;
|
||||
cost = 100;
|
||||
underTarget = 5;
|
||||
target = 12;
|
||||
cost = 50;
|
||||
mortalServantValue = 2;
|
||||
break;
|
||||
case 2:
|
||||
underTarget = 30;
|
||||
target = 70;
|
||||
cost = 125;
|
||||
underTarget = 10;
|
||||
target = 18;
|
||||
cost = 75;
|
||||
mortalServantValue = 3;
|
||||
break;
|
||||
case 3:
|
||||
underTarget = 50;
|
||||
target = 100;
|
||||
cost = 150;
|
||||
underTarget = 14;
|
||||
target = 23;
|
||||
cost = 100;
|
||||
mortalServantValue = 10;
|
||||
break;
|
||||
}
|
||||
@ -247,7 +266,7 @@ export let bat3 = table.add({
|
||||
});
|
||||
|
||||
export let stealth0 = table.add({
|
||||
governing: governing("stealth", 0),
|
||||
governing: governing("stealth", -0.25),
|
||||
profile: {
|
||||
name: "Be Quiet",
|
||||
description:
|
||||
@ -284,7 +303,7 @@ export let stealth3 = table.add({
|
||||
});
|
||||
|
||||
export let charm0 = table.add({
|
||||
governing: governing("charm", 0),
|
||||
governing: governing("charm", -0.125),
|
||||
profile: {
|
||||
name: "Flatter",
|
||||
description:
|
||||
|
@ -2,16 +2,11 @@ import { getPartLocation, withCamera } from "./layout.ts";
|
||||
import { AlignX, Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { D } from "./engine/public.ts";
|
||||
import {
|
||||
BG_INSET,
|
||||
FG_BOLD,
|
||||
FG_TEXT_DISABLED,
|
||||
FG_TEXT_ENDORSED,
|
||||
} from "./colors.ts";
|
||||
import { addButton } from "./button.ts";
|
||||
import { getSkills } from "./skills.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { Skill, SkillData } from "./datatypes.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
export class SkillsModal {
|
||||
#drawpile: DrawPile;
|
||||
@ -50,7 +45,7 @@ export class SkillsModal {
|
||||
this.#drawpile.clear();
|
||||
let size = this.#size;
|
||||
this.#drawpile.add(0, () => {
|
||||
D.fillRect(new Point(-4, -4), size.add(new Size(8, 8)), BG_INSET);
|
||||
D.fillRect(new Point(-4, -4), size.add(new Size(8, 8)), C.BG_UI);
|
||||
});
|
||||
|
||||
// draw skills
|
||||
@ -71,24 +66,24 @@ export class SkillsModal {
|
||||
0,
|
||||
(hover) => {
|
||||
// two column layout
|
||||
let [bg, fg] = [BG_INSET, FG_BOLD];
|
||||
let [bg, fg] = [C.BG_UI, C.FG_BOLD];
|
||||
|
||||
let overpriced =
|
||||
getSkills().computeCost(skill) >
|
||||
getPlayerProgress().getExperience();
|
||||
let atMinimum = getSkills().isAtMinimum(skill);
|
||||
if (overpriced) {
|
||||
fg = FG_TEXT_DISABLED;
|
||||
fg = C.FG_TEXT_DISABLED;
|
||||
} else if (atMinimum) {
|
||||
fg = FG_TEXT_ENDORSED;
|
||||
fg = C.FG_TEXT_ENDORSED;
|
||||
}
|
||||
|
||||
if (selected || hover) {
|
||||
[bg, fg] = [FG_BOLD, BG_INSET];
|
||||
[bg, fg] = [C.FG_BOLD, C.BG_UI];
|
||||
if (overpriced) {
|
||||
// still use the same BG, for contrast
|
||||
} else if (atMinimum) {
|
||||
bg = FG_TEXT_ENDORSED;
|
||||
bg = C.FG_TEXT_ENDORSED;
|
||||
}
|
||||
}
|
||||
D.fillRect(skillRect.top, skillRect.size, bg);
|
||||
@ -117,8 +112,8 @@ export class SkillsModal {
|
||||
let remainingWidth = size.w - 160;
|
||||
|
||||
this.#drawpile.add(0, () => {
|
||||
D.fillRect(new Point(160, 0), new Size(remainingWidth, 96), FG_BOLD);
|
||||
D.drawText(createFullDescription(data), new Point(164, 0), BG_INSET, {
|
||||
D.fillRect(new Point(160, 0), new Size(remainingWidth, 96), C.FG_BOLD);
|
||||
D.drawText(createFullDescription(data), new Point(164, 0), C.BG_UI, {
|
||||
forceWidth: remainingWidth - 8,
|
||||
});
|
||||
});
|
||||
|
53
src/sound.ts
Normal file
@ -0,0 +1,53 @@
|
||||
class SoundShared {
|
||||
readonly context: AudioContext;
|
||||
bgmSource: AudioBufferSourceNode | null;
|
||||
bgmGain: GainNode | null;
|
||||
|
||||
constructor() {
|
||||
this.context = new AudioContext();
|
||||
this.bgmSource = null;
|
||||
this.bgmGain = null;
|
||||
}
|
||||
}
|
||||
const shared = new SoundShared();
|
||||
|
||||
export class Sound {
|
||||
#link: string;
|
||||
#audioBufferPromise: Promise<AudioBuffer>;
|
||||
|
||||
constructor(link: string) {
|
||||
this.#link = link;
|
||||
this.#audioBufferPromise = this.#unsafeGetAudioBuffer();
|
||||
}
|
||||
async #unsafeGetAudioBuffer(): Promise<AudioBuffer> {
|
||||
let resp = await fetch(this.#link);
|
||||
let buf = await resp.arrayBuffer();
|
||||
return await shared.context.decodeAudioData(buf);
|
||||
}
|
||||
|
||||
async #getAudioBuffer(): Promise<AudioBuffer> {
|
||||
return await this.#audioBufferPromise;
|
||||
}
|
||||
|
||||
play(options?: { volume?: number; bgm?: boolean }) {
|
||||
this.#getAudioBuffer().then((adata) => {
|
||||
let source = shared.context.createBufferSource();
|
||||
source.buffer = adata;
|
||||
let gain = shared.context.createGain();
|
||||
gain.gain.value = options?.volume ?? 1.0;
|
||||
source.connect(gain);
|
||||
gain.connect(shared.context.destination);
|
||||
source.start();
|
||||
|
||||
if (options?.bgm) {
|
||||
shared.bgmSource?.stop(shared.context.currentTime + 1);
|
||||
shared.bgmGain?.gain?.linearRampToValueAtTime(
|
||||
0.0,
|
||||
shared.context.currentTime + 1,
|
||||
);
|
||||
shared.bgmSource = source;
|
||||
shared.bgmGain = gain;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
53
src/sounds.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import audBite from "./art/sounds/bite.mp3";
|
||||
import audCollect from "./art/sounds/collect.mp3";
|
||||
import audDeath from "./art/sounds/death.mp3";
|
||||
import audDig from "./art/sounds/dig.mp3";
|
||||
import audEnding from "./art/sounds/ending.mp3";
|
||||
import audRecruit from "./art/sounds/recruit.mp3";
|
||||
import audRewardBig from "./art/sounds/reward_big.mp3";
|
||||
import audRewardHuge from "./art/sounds/reward_huge.mp3";
|
||||
import audRewardMedium from "./art/sounds/reward_medium.mp3";
|
||||
import audRewardSmall from "./art/sounds/reward_small.mp3";
|
||||
import audSilence from "./art/sounds/silence.mp3";
|
||||
import audSleep from "./art/sounds/sleep.mp3";
|
||||
import audVnBat from "./art/sounds/vn_bat.mp3";
|
||||
import audVnBreath from "./art/sounds/vn_breath.mp3";
|
||||
import audVnDance from "./art/sounds/vn_dance.mp3";
|
||||
import audVnDoorbell from "./art/sounds/vn_doorbell.mp3";
|
||||
import audVnGhost from "./art/sounds/vn_ghost.mp3";
|
||||
import audVnPage from "./art/sounds/vn_page.mp3";
|
||||
import audVnPhone from "./art/sounds/vn_phone.mp3";
|
||||
import { Sound } from "./sound.ts";
|
||||
|
||||
export let sndBite = new Sound(audBite);
|
||||
export let sndCollect = new Sound(audCollect);
|
||||
export let sndDeath = new Sound(audDeath);
|
||||
export let sndDig = new Sound(audDig);
|
||||
export let sndEnding = new Sound(audEnding);
|
||||
export let sndRecruit = new Sound(audRecruit);
|
||||
export let sndRewardBig = new Sound(audRewardBig);
|
||||
export let sndRewardHuge = new Sound(audRewardHuge);
|
||||
export let sndRewardMedium = new Sound(audRewardMedium);
|
||||
export let sndRewardSmall = new Sound(audRewardSmall);
|
||||
export let sndSilence = new Sound(audSilence);
|
||||
export let sndSleep = new Sound(audSleep);
|
||||
export let sndVnBat = new Sound(audVnBat);
|
||||
export let sndVnBreath = new Sound(audVnBreath);
|
||||
export let sndVnDance = new Sound(audVnDance);
|
||||
export let sndVnDoorbell = new Sound(audVnDoorbell);
|
||||
export let sndVnGhost = new Sound(audVnGhost);
|
||||
export let sndVnPage = new Sound(audVnPage);
|
||||
export let sndVnPhone = new Sound(audVnPhone);
|
||||
|
||||
export function sndRewardFor(amount: number) {
|
||||
if (amount <= 1) {
|
||||
return sndRewardSmall;
|
||||
}
|
||||
if (amount <= 2) {
|
||||
return sndRewardMedium;
|
||||
}
|
||||
if (amount <= 3) {
|
||||
return sndRewardBig;
|
||||
}
|
||||
return sndRewardHuge;
|
||||
}
|
@ -1,38 +1,111 @@
|
||||
import { getPlayerProgress, initPlayerProgress } from "./playerprogress.ts";
|
||||
import {
|
||||
getPlayerProgress,
|
||||
initPlayerProgress,
|
||||
rehydratePlayerProgress,
|
||||
} from "./playerprogress.ts";
|
||||
import { getHuntMode, HuntMode, initHuntMode } from "./huntmode.ts";
|
||||
import { getVNModal } from "./vnmodal.ts";
|
||||
import { getScorer } from "./scorer.ts";
|
||||
import { getEndgameModal } from "./endgamemodal.ts";
|
||||
import { SuccessorOption, Wish } from "./datatypes.ts";
|
||||
import { generateManor } from "./manormap.ts";
|
||||
import { sndSilence, sndSleep } from "./sounds.ts";
|
||||
import { openingScene } from "./openingscene.ts";
|
||||
import { generateName } from "./namegen.ts";
|
||||
import { photogenicThralls } from "./thralls.ts";
|
||||
import { choose } from "./utils.ts";
|
||||
import { SaveFileV1 } from "./saveformat.ts";
|
||||
import { readBestSave, saveGame } from "./save.ts";
|
||||
|
||||
const N_TURNS: number = 9;
|
||||
|
||||
export class StateManager {
|
||||
#turn: number;
|
||||
#revision: number;
|
||||
|
||||
constructor() {
|
||||
this.#turn = 1;
|
||||
constructor(file?: SaveFileV1) {
|
||||
this.#turn = file?.turn ?? 1;
|
||||
this.#revision = file?.revision ?? 1;
|
||||
}
|
||||
|
||||
getTurn(): number {
|
||||
return this.#turn;
|
||||
}
|
||||
|
||||
nextRevision(): number {
|
||||
this.#revision++;
|
||||
return this.#revision;
|
||||
}
|
||||
|
||||
startOrLoadFirstGame() {
|
||||
let save = readBestSave();
|
||||
if (save.file != null || save.error != null) {
|
||||
const file = save.file;
|
||||
const error = save.error;
|
||||
getVNModal().play([
|
||||
{
|
||||
type: "saveGameScreen",
|
||||
file: file,
|
||||
error: error,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.startFirstGame();
|
||||
}
|
||||
startFirstGame() {
|
||||
getVNModal().play([
|
||||
...openingScene,
|
||||
{
|
||||
type: "callback",
|
||||
callback: () => {
|
||||
this.startGame(
|
||||
{
|
||||
name: generateName(),
|
||||
template: choose(photogenicThralls),
|
||||
nImprovements: 0,
|
||||
title: "",
|
||||
note: null,
|
||||
stats: { AGI: 10, INT: 10, CHA: 10, PSI: 10 },
|
||||
talents: { AGI: 0, INT: 0, CHA: 0, PSI: 0 },
|
||||
skills: [],
|
||||
isCompulsory: false,
|
||||
inPenance: false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
startGame(asSuccessor: SuccessorOption, withWish: Wish | null) {
|
||||
this.#turn = 1;
|
||||
initPlayerProgress(asSuccessor, withWish);
|
||||
initHuntMode(new HuntMode(1, generateManor()));
|
||||
sndSleep.play({ bgm: true });
|
||||
}
|
||||
|
||||
resumeGame(saveFile: SaveFileV1) {
|
||||
// hack: prepare depth which advance() uses
|
||||
this.#turn = saveFile.turn;
|
||||
this.#revision = saveFile.revision;
|
||||
rehydratePlayerProgress(saveFile);
|
||||
initHuntMode(new HuntMode(saveFile.depth, generateManor()));
|
||||
this.advance();
|
||||
}
|
||||
|
||||
advance() {
|
||||
saveGame();
|
||||
if (this.#turn + 1 <= N_TURNS) {
|
||||
this.#turn += 1;
|
||||
getPlayerProgress().applyEndOfTurn();
|
||||
getPlayerProgress().refill();
|
||||
initHuntMode(new HuntMode(getHuntMode().depth, generateManor()));
|
||||
sndSleep.play({ bgm: true });
|
||||
} else {
|
||||
// TODO: Play a specific scene
|
||||
sndSilence.play({ bgm: true });
|
||||
let ending = getScorer().pickEnding();
|
||||
getVNModal().play(ending.scene);
|
||||
getEndgameModal().show(ending);
|
||||
|
@ -2,6 +2,7 @@ import { ALL_STATS, Skill, Stat, SuccessorOption } from "./datatypes.ts";
|
||||
import { generateName, generateTitle } from "./namegen.ts";
|
||||
import { choose } from "./utils.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { photogenicThralls } from "./thralls.ts";
|
||||
|
||||
export function generateSuccessors(
|
||||
nImprovements: number,
|
||||
@ -35,6 +36,8 @@ export function generateSuccessorFromPlayer(): SuccessorOption {
|
||||
let progress = getPlayerProgress();
|
||||
let successor = {
|
||||
name: progress.name,
|
||||
template: progress.template,
|
||||
nImprovements: progress.nImprovements - 2,
|
||||
title: "Penitent",
|
||||
note: "Failed at Master's bidding",
|
||||
stats: { ...progress.getStats() },
|
||||
@ -52,6 +55,7 @@ export function generateSuccessorFromPlayer(): SuccessorOption {
|
||||
|
||||
export function generateSuccessor(nImprovements: number): SuccessorOption {
|
||||
let name = generateName();
|
||||
let template = choose(photogenicThralls);
|
||||
let title = generateTitle();
|
||||
let note = null;
|
||||
let stats: Record<Stat, number> = {
|
||||
@ -75,8 +79,9 @@ export function generateSuccessor(nImprovements: number): SuccessorOption {
|
||||
talents[choose(ALL_STATS)] += 1;
|
||||
},
|
||||
];
|
||||
let nTotalImprovements = nImprovements + 5;
|
||||
for (let i = 0; i < nTotalImprovements; i++) {
|
||||
let nTotalImprovements = nImprovements;
|
||||
let mult = 1;
|
||||
for (let i = 0; i < nTotalImprovements * mult; i++) {
|
||||
let improvement =
|
||||
improvements[Math.floor(Math.random() * improvements.length)];
|
||||
improvement();
|
||||
@ -85,5 +90,16 @@ export function generateSuccessor(nImprovements: number): SuccessorOption {
|
||||
let skills: Skill[] = [];
|
||||
let inPenance = false;
|
||||
let isCompulsory = false;
|
||||
return { name, title, note, stats, talents, skills, inPenance, isCompulsory };
|
||||
return {
|
||||
name,
|
||||
template,
|
||||
nImprovements,
|
||||
title,
|
||||
note,
|
||||
stats,
|
||||
talents,
|
||||
skills,
|
||||
inPenance,
|
||||
isCompulsory,
|
||||
};
|
||||
}
|
||||
|
172
src/thralls.ts
@ -22,7 +22,7 @@ import {
|
||||
sprThrallStealth,
|
||||
} from "./sprites.ts";
|
||||
import { Sprite } from "./engine/internal/sprite.ts";
|
||||
import {Stat} from "./datatypes.ts";
|
||||
import { Stat } from "./datatypes.ts";
|
||||
|
||||
export type Thrall = {
|
||||
id: number;
|
||||
@ -52,10 +52,15 @@ class ThrallsTable {
|
||||
}
|
||||
return thralls;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.#thralls.length;
|
||||
}
|
||||
}
|
||||
export type ThrallData = {
|
||||
label: string;
|
||||
sprite: Sprite;
|
||||
hitboxSize: number;
|
||||
posterCheck: CheckData;
|
||||
initialCheck: CheckData;
|
||||
itemHint: string;
|
||||
@ -96,9 +101,14 @@ export function getThralls() {
|
||||
// Their initial check is, generally, the initial check of the
|
||||
// thrall n-2 or thrall n+1 (ex: Party's initial check is Stealth
|
||||
// or Lore)
|
||||
//
|
||||
// I then made some swaps:
|
||||
// - Garrett gets Flatter instead of Respect Elders
|
||||
// - Monica gets Respect Elders instead of Flatter
|
||||
export let thrallParty = table.add({
|
||||
label: "Garrett",
|
||||
sprite: sprThrallParty,
|
||||
hitboxSize: 0.7,
|
||||
posterCheck: {
|
||||
label:
|
||||
"This room would be perfect for someone with an ostensibly managed gambling addiction.",
|
||||
@ -110,18 +120,19 @@ export let thrallParty = table.add({
|
||||
options: [
|
||||
{
|
||||
skill: () => stealth1, // Disguise
|
||||
locked: '"What\'s wrong, Garrett?"',
|
||||
locked: '"What\'s up?"',
|
||||
failure:
|
||||
"\"If you're not a large pile of money, don't talk to me.\"\n\nHe sobs into his ice cream.",
|
||||
unlockable: "*look like a large pile of money*",
|
||||
success: "He scoops you eagerly into his wallet.",
|
||||
},
|
||||
{
|
||||
skill: () => lore0, // Respect Elders
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: "TODO",
|
||||
success: "TODO",
|
||||
skill: () => charm0, // Flatter
|
||||
locked: "Ask him how much he's winning",
|
||||
failure: 'He rolls his eyes at you. "A billion."',
|
||||
unlockable: "Tell him he's cute",
|
||||
success:
|
||||
"He looks at you like no one has ever told him that before, and blushes. You hold his wing and feel his pulse rise.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -130,10 +141,12 @@ export let thrallParty = table.add({
|
||||
itemPickupMessage:
|
||||
"This antique wedding ring looks like it was worth at least fifty big blinds.",
|
||||
deliveryMessage:
|
||||
'"Oh, that? Yeah, I won it." And then lost it, apparently.\n\nHe\'s elated. He will never leave.',
|
||||
'"Oh, that? Yeah, I won it." And then lost it, apparently.\n\nHe\'s elated.',
|
||||
rewardMessage: "Garrett showers you with INT!",
|
||||
rewardCallback: (spawn) => {
|
||||
for (let i = 0; i < 30; i++) { spawn("INT"); }
|
||||
for (let i = 0; i < 12; i++) {
|
||||
spawn("INT");
|
||||
}
|
||||
},
|
||||
lifeStageText: {
|
||||
fresh: {
|
||||
@ -170,6 +183,7 @@ export let thrallParty = table.add({
|
||||
export let thrallLore = table.add({
|
||||
label: "Lupin",
|
||||
sprite: sprThrallLore,
|
||||
hitboxSize: 0.65,
|
||||
posterCheck: {
|
||||
label:
|
||||
"This room would be perfect for someone with a love of nature and screaming.",
|
||||
@ -181,16 +195,17 @@ export let thrallLore = table.add({
|
||||
options: [
|
||||
{
|
||||
skill: () => stare1, // Hypnotize
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: '"I\'m a wolf too."',
|
||||
locked: '"I\'m a wolf too."',
|
||||
failure: '"AROO?" He shakes his head no.',
|
||||
unlockable: 'Zonk him. "I\'m a wolf now."',
|
||||
success:
|
||||
"He blinks a few times under your gaze -- then touches your muzzle -- then his own -- then arfs submissively.",
|
||||
},
|
||||
{
|
||||
skill: () => bat0, // Screech
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
locked: "Try to yowl",
|
||||
failure:
|
||||
"He sniffs at your collar. You don't _seem_ like a Wolf Scout.",
|
||||
unlockable: '"Wolf Scouts AWOO!"',
|
||||
success:
|
||||
"Taken aback at how well you know the cheer, he freezes -- then joins you with a similar howl.",
|
||||
@ -202,10 +217,12 @@ export let thrallLore = table.add({
|
||||
itemPickupMessage:
|
||||
"You can't see yourself in this antique silver mirror. On the other hand, they say silver is effective against wolves.",
|
||||
deliveryMessage:
|
||||
"Lupin looks at his own reflection -- with interest, confusion, dismissal, and then deep satisfaction. He loves it. He will never leave.",
|
||||
"Lupin looks for his own reflection -- with interest, confusion, dismissal, and then deep satisfaction.",
|
||||
rewardMessage: "Lupin showers you with AGI!",
|
||||
rewardCallback: (spawn) => {
|
||||
for (let i = 0; i < 30; i++) { spawn("AGI"); }
|
||||
for (let i = 0; i < 12; i++) {
|
||||
spawn("AGI");
|
||||
}
|
||||
},
|
||||
lifeStageText: {
|
||||
fresh: {
|
||||
@ -240,29 +257,31 @@ export let thrallLore = table.add({
|
||||
export let thrallBat = table.add({
|
||||
label: "Monica",
|
||||
sprite: sprThrallBat,
|
||||
hitboxSize: 0.5,
|
||||
posterCheck: {
|
||||
label: "This room would be perfect for some kind of television chef.",
|
||||
options: [],
|
||||
},
|
||||
initialCheck: {
|
||||
label:
|
||||
"That's Monica. You've seen her cook on TV! Looks like she's enjoying a kiwi flan.",
|
||||
"That's Monica, the evil judge from MasterCook. A bit older than you, and you don't age anymore. She's enjoying a kiwi flan -- it looks good.",
|
||||
options: [
|
||||
{
|
||||
skill: () => party1, // Rave
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: "Slide her a sachet of cocaine.",
|
||||
locked: "Act weird to get her attention",
|
||||
failure: '"I -- you -- you know we\'re not being filmed, right?"',
|
||||
unlockable: "Flash your eyes like a TV camera",
|
||||
success:
|
||||
"\"No way. Ketamine if you've got it.\" You do.\n\n(It's not effective on vampires.)",
|
||||
"The clash of light and color causes some kind of flashback.\nYou convince her that she's on set. She agrees to follow you backstage.",
|
||||
},
|
||||
{
|
||||
skill: () => charm0, // Flatter
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: '"You\'re the best cook ever!"',
|
||||
skill: () => lore0, // Respect Elders
|
||||
locked: '"That looks good."',
|
||||
failure:
|
||||
"\"It's not.\" She eats it with such relish it's hard to tell.",
|
||||
unlockable: "Not as good as they used to be",
|
||||
success:
|
||||
'"Settle down!" she says, lowering your volume with a sweep of her hand. "It\'s true though."',
|
||||
'"Certainly." She seems pleased that you understand.\n\n"Teach me to make a real one?"',
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -271,11 +290,15 @@ export let thrallBat = table.add({
|
||||
itemPickupMessage:
|
||||
"This particular instance of gator food resembles an infamous Aotearoan entree: colonial goose.",
|
||||
deliveryMessage:
|
||||
'Monica salivates. "This is... this is... simply exquisite!"\n\nShe is happy. She will never leave.',
|
||||
'Monica salivates. "This is... this is... simply exquisite!"',
|
||||
rewardMessage: "Monica showers you with CHA and INT!",
|
||||
rewardCallback: (spawn) => {
|
||||
for (let i = 0; i < 15; i++) { spawn("CHA"); }
|
||||
for (let i = 0; i < 15; i++) { spawn("INT"); }
|
||||
for (let i = 0; i < 8; i++) {
|
||||
spawn("CHA");
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
spawn("INT");
|
||||
}
|
||||
},
|
||||
lifeStageText: {
|
||||
fresh: {
|
||||
@ -310,6 +333,7 @@ export let thrallBat = table.add({
|
||||
export let thrallCharm = table.add({
|
||||
label: "Renfield",
|
||||
sprite: sprThrallCharm,
|
||||
hitboxSize: 0.85,
|
||||
posterCheck: {
|
||||
label:
|
||||
"This room would be perfect for someone who likes vampires even more than you enjoy being a vampire.",
|
||||
@ -321,17 +345,18 @@ export let thrallCharm = table.add({
|
||||
options: [
|
||||
{
|
||||
skill: () => lore1, // Brick by Brick
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: '"Wanna see my crypt?"',
|
||||
locked: `Ask for vampire facts`,
|
||||
failure: `He eagerly describes the cape, the coffin, the crypt, the castle -- a whole lot of stuff you don't necessarily don't have.`,
|
||||
unlockable: `Talk about your manor`,
|
||||
success:
|
||||
'He salivates -- swallowing hard before he manages, in response to the prospect, a firm "YES!"',
|
||||
"You describe your manor for a while without inviting him. He salivates -- swallowing hard before he manages, in response to your comments, to beg for a peek.",
|
||||
},
|
||||
{
|
||||
skill: () => stealth0, // Be Quiet
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: "Say absolutely nothing.",
|
||||
locked: "Get your fangs in his face",
|
||||
failure:
|
||||
'"Wow. You\'re -- wow! Wow! Wow!"\n\n"And what\'s more --" you say, giving him far too much of a desirable thing.',
|
||||
unlockable: "Say absolutely nothing",
|
||||
success:
|
||||
"His mind overflows with fantasy, and when you let a glint of fang peek through, he claps his arms affectionately around your supercold torso.",
|
||||
},
|
||||
@ -341,10 +366,12 @@ export let thrallCharm = table.add({
|
||||
itemPickupMessage:
|
||||
"Your photo is going to be in a lot of places if it gets out, but you've got the original.",
|
||||
deliveryMessage:
|
||||
"Renfield inhales sharply and widens his stance, trying to hide his physical reaction to your face. He is elated and will never leave.",
|
||||
"Renfield inhales sharply and widens his stance, trying to hide his physical reaction to your face. He is elated.",
|
||||
rewardMessage: "Renfield showers you with PSI!",
|
||||
rewardCallback: (spawn) => {
|
||||
for (let i = 0; i < 24; i++) { spawn("PSI"); }
|
||||
for (let i = 0; i < 12; i++) {
|
||||
spawn("PSI");
|
||||
}
|
||||
},
|
||||
lifeStageText: {
|
||||
fresh: {
|
||||
@ -379,6 +406,7 @@ export let thrallCharm = table.add({
|
||||
export let thrallStealth = table.add({
|
||||
label: "Narthyss",
|
||||
sprite: sprThrallStealth,
|
||||
hitboxSize: 0.85,
|
||||
posterCheck: {
|
||||
label: "This room would be perfect for someone who can breathe fire.",
|
||||
options: [],
|
||||
@ -389,17 +417,21 @@ export let thrallStealth = table.add({
|
||||
options: [
|
||||
{
|
||||
skill: () => bat1, // Flap
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: "Hang upside-down and offer her a martini.",
|
||||
success: "\"You're ADORABLE!\" She's yours forever.",
|
||||
locked: "Drink cherry soda with your fangs",
|
||||
failure:
|
||||
'"Wow! That\'s incredibly cool," she says. Her eyes scan the other patrons. "Can I get you anything?"',
|
||||
unlockable: "Hang upside-down and offer a drink",
|
||||
success:
|
||||
'"You\'re ADORABLE!" Her attention is yours alone and she offers to follow you home.',
|
||||
},
|
||||
{
|
||||
skill: () => stare0, // Dazzle
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: "TODO",
|
||||
success: "TODO",
|
||||
locked: "Show her some sparks",
|
||||
failure:
|
||||
'"Neat trick!" she says. She blinds you with a glowstick, then heads back into the crowd.',
|
||||
unlockable: "Trap her in a tunnel of light",
|
||||
success:
|
||||
"She blushes bright peach, unable to perceive any exit until you approach -- close enough to feel her warm, warm breath. She plants a kiss on your lips.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -408,11 +440,15 @@ export let thrallStealth = table.add({
|
||||
itemPickupMessage:
|
||||
"The freezer is empty except for this frozen kobold, who mutters something about collecting blood for its master.",
|
||||
deliveryMessage:
|
||||
"\"That? That's not mine.\" But she wants it. Now it's hers. She will never leave.",
|
||||
"\"That? That's not mine.\" But she wants it. Now it's hers.",
|
||||
rewardMessage: "Narthyss showers you with CHA and AGI!",
|
||||
rewardCallback: (spawn) => {
|
||||
for (let i = 0; i < 15; i++) { spawn("CHA"); }
|
||||
for (let i = 0; i < 15; i++) { spawn("AGI"); }
|
||||
for (let i = 0; i < 8; i++) {
|
||||
spawn("CHA");
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
spawn("AGI");
|
||||
}
|
||||
},
|
||||
lifeStageText: {
|
||||
fresh: {
|
||||
@ -447,6 +483,7 @@ export let thrallStealth = table.add({
|
||||
export let thrallStare = table.add({
|
||||
label: "Ridley",
|
||||
sprite: sprThrallStare,
|
||||
hitboxSize: 0.85,
|
||||
posterCheck: {
|
||||
label: "This room would be perfect for a soulless robot.",
|
||||
options: [],
|
||||
@ -460,15 +497,18 @@ export let thrallStare = table.add({
|
||||
locked: "\"How many Rs in 'strawberry'?\"",
|
||||
failure:
|
||||
"It generates an image of a sad fruit shrugging in a muddy plantation.",
|
||||
unlockable: "TODO",
|
||||
success: "TODO",
|
||||
unlockable: '"Do you want to come home with me?"',
|
||||
success:
|
||||
"It generates an image of a happy robot riding the shoulders of a tiny little bat.",
|
||||
},
|
||||
{
|
||||
skill: () => party0, // Chug
|
||||
locked: "TODO",
|
||||
failure: "TODO",
|
||||
unlockable: "Drink a whole bottle of ink.",
|
||||
success: "TODO",
|
||||
locked: "Check its fluid levels",
|
||||
failure:
|
||||
"It submits to your examination, revealing ordinary quantities of robot blood, robot lubricant, Ener-G, and headlight fluid.",
|
||||
unlockable: "Drink its battery",
|
||||
success:
|
||||
"The sulfuric acid doesn't burn you. This somehow makes Ridley want to hang out with you.\n\n(Ridley is weird.)",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -477,10 +517,12 @@ export let thrallStare = table.add({
|
||||
itemPickupMessage:
|
||||
"This glinting gear would be perfect for a malfunctioning robot.",
|
||||
deliveryMessage:
|
||||
"Ridley admires the gear but -- to your surprise -- refuses to jam it into its brain.\n\nThe pup is elated and will never leave.",
|
||||
"Ridley admires the gear but -- to your surprise -- refuses to jam it into its brain.\n\nThe pup is elated.",
|
||||
rewardMessage: "Ridley showers you with EXP!",
|
||||
rewardCallback: (spawn) => {
|
||||
for (let i = 0; i < 6; i++) { spawn("EXP"); }
|
||||
for (let i = 0; i < 12; i++) {
|
||||
spawn("EXP");
|
||||
}
|
||||
},
|
||||
lifeStageText: {
|
||||
fresh: {
|
||||
@ -509,3 +551,19 @@ export let thrallStare = table.add({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export let photogenicThralls = [
|
||||
thrallParty,
|
||||
thrallParty,
|
||||
thrallParty,
|
||||
thrallLore,
|
||||
thrallLore,
|
||||
thrallLore,
|
||||
thrallCharm,
|
||||
thrallCharm,
|
||||
thrallCharm,
|
||||
thrallStealth,
|
||||
thrallStealth,
|
||||
thrallStealth,
|
||||
thrallBat,
|
||||
];
|
||||
|
@ -29,8 +29,18 @@ import {
|
||||
thrallStare,
|
||||
thrallStealth,
|
||||
} from "./thralls.ts";
|
||||
import {
|
||||
Microtheme,
|
||||
MICROTHEME_GREEN,
|
||||
MICROTHEME_NEON,
|
||||
MICROTHEME_PINK,
|
||||
MICROTHEME_PURPLE_TAN,
|
||||
MICROTHEME_RED,
|
||||
MICROTHEME_TEAL,
|
||||
} from "./colors.ts";
|
||||
|
||||
export type VaultTemplate = {
|
||||
microtheme: () => Microtheme | null;
|
||||
stats: { primary: Stat; secondary: Stat };
|
||||
roomLabels: {
|
||||
hall: string;
|
||||
@ -45,6 +55,7 @@ export type VaultTemplate = {
|
||||
export const standardVaultTemplates: VaultTemplate[] = [
|
||||
{
|
||||
// zoo
|
||||
microtheme: () => MICROTHEME_GREEN,
|
||||
stats: { primary: "AGI", secondary: "PSI" },
|
||||
roomLabels: {
|
||||
hall: "Zoo",
|
||||
@ -97,6 +108,7 @@ export const standardVaultTemplates: VaultTemplate[] = [
|
||||
},
|
||||
{
|
||||
// blood bank
|
||||
microtheme: () => MICROTHEME_RED,
|
||||
stats: { primary: "AGI", secondary: "INT" },
|
||||
roomLabels: {
|
||||
hall: "Blood Bank",
|
||||
@ -148,6 +160,7 @@ export const standardVaultTemplates: VaultTemplate[] = [
|
||||
},
|
||||
{
|
||||
// coffee shop
|
||||
microtheme: () => MICROTHEME_PINK,
|
||||
stats: { primary: "PSI", secondary: "CHA" },
|
||||
roomLabels: {
|
||||
hall: "Coffee Shop",
|
||||
@ -200,6 +213,7 @@ export const standardVaultTemplates: VaultTemplate[] = [
|
||||
},
|
||||
{
|
||||
// optometrist
|
||||
microtheme: () => MICROTHEME_TEAL,
|
||||
stats: { primary: "PSI", secondary: "PSI" },
|
||||
roomLabels: {
|
||||
hall: "Optometrist",
|
||||
@ -252,6 +266,7 @@ export const standardVaultTemplates: VaultTemplate[] = [
|
||||
},
|
||||
{
|
||||
// club,
|
||||
microtheme: () => MICROTHEME_NEON,
|
||||
stats: { primary: "CHA", secondary: "PSI" },
|
||||
roomLabels: {
|
||||
hall: "Nightclub",
|
||||
@ -304,6 +319,7 @@ export const standardVaultTemplates: VaultTemplate[] = [
|
||||
},
|
||||
{
|
||||
// library
|
||||
microtheme: () => MICROTHEME_PURPLE_TAN,
|
||||
stats: { primary: "INT", secondary: "CHA" },
|
||||
roomLabels: {
|
||||
hall: "Library",
|
||||
|
134
src/vnmodal.ts
@ -1,8 +1,13 @@
|
||||
import { D, I } from "./engine/public.ts";
|
||||
import { AlignX, AlignY, Point } from "./engine/datatypes.ts";
|
||||
import { FG_BOLD } from "./colors.ts";
|
||||
import { AlignX, AlignY, Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { withCamera } from "./layout.ts";
|
||||
import { VNScene, VNSceneMessage, VNScenePart } from "./vnscene.ts";
|
||||
import { C } from "./colors.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { wipeSaves } from "./save.ts";
|
||||
import { SaveFileV1 } from "./saveformat.ts";
|
||||
import { addButton } from "./button.ts";
|
||||
import { getStateManager } from "./statemanager.ts";
|
||||
|
||||
const WIDTH = 384;
|
||||
const HEIGHT = 384;
|
||||
@ -85,7 +90,13 @@ interface SceneCathexis {
|
||||
function createCathexis(part: VNScenePart): SceneCathexis {
|
||||
switch (part.type) {
|
||||
case "message":
|
||||
part?.sfx?.play({ volume: 0.5 });
|
||||
return new SceneMessageCathexis(part);
|
||||
case "callback":
|
||||
part?.callback();
|
||||
return new SkipCathexis();
|
||||
case "saveGameScreen":
|
||||
return new SaveGameCathexis(part.file, part.error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,18 +119,22 @@ class SceneMessageCathexis {
|
||||
let firstFrame = !this.#gotOneFrame;
|
||||
this.#gotOneFrame = true;
|
||||
|
||||
// TODO: SFX
|
||||
if (!firstFrame && I.isAnythingPressed()) {
|
||||
if (!firstFrame && I.isMouseClicked("leftMouse")) {
|
||||
this.#done = true;
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
D.drawText(this.#message.text, new Point(WIDTH / 2, HEIGHT / 2), FG_BOLD, {
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
forceWidth: WIDTH,
|
||||
});
|
||||
D.drawText(
|
||||
this.#message.text,
|
||||
new Point(WIDTH / 2, HEIGHT / 2),
|
||||
C.FG_BOLD,
|
||||
{
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
forceWidth: WIDTH,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,3 +142,104 @@ let active: VNModal = new VNModal();
|
||||
export function getVNModal() {
|
||||
return active;
|
||||
}
|
||||
|
||||
class SkipCathexis {
|
||||
constructor() {}
|
||||
|
||||
isDone() {
|
||||
return true;
|
||||
}
|
||||
|
||||
update() {
|
||||
throw new Error("shouldn't be updated");
|
||||
}
|
||||
|
||||
draw() {
|
||||
throw new Error("shouldn't ever be drawn");
|
||||
}
|
||||
}
|
||||
|
||||
class SaveGameCathexis {
|
||||
#drawpile: DrawPile;
|
||||
#file: SaveFileV1 | null;
|
||||
#error: string | null;
|
||||
#done: boolean;
|
||||
|
||||
constructor(file: SaveFileV1 | null, error: string | null) {
|
||||
this.#drawpile = new DrawPile();
|
||||
this.#file = file;
|
||||
this.#error = error;
|
||||
this.#done = false;
|
||||
}
|
||||
|
||||
isDone() {
|
||||
return this.#done;
|
||||
}
|
||||
|
||||
update() {
|
||||
let name = this.#file?.name;
|
||||
let turn = this.#file?.turn ?? 0;
|
||||
let turnText = turn < 9 ? `${name}, Turn ${turn + 1}` : "Sentence of Fate";
|
||||
this.#drawpile.clear();
|
||||
this.#drawpile.add(0, () => {
|
||||
D.drawText(
|
||||
this.#error && this.#file
|
||||
? `A save was invalid. Continue from an alternate save?
|
||||
|
||||
${this.#error}`
|
||||
: this.#error
|
||||
? `Your save was invalid:
|
||||
|
||||
${this.#error}`
|
||||
: "Resume from save?",
|
||||
new Point(WIDTH / 2, HEIGHT / 2),
|
||||
C.FG_BOLD,
|
||||
{
|
||||
alignX: AlignX.Center,
|
||||
alignY: AlignY.Middle,
|
||||
forceWidth: WIDTH,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
addButton(
|
||||
this.#drawpile,
|
||||
"Clear Save",
|
||||
new Rect(new Point(0, HEIGHT - 32), new Size(128, 32)),
|
||||
this.#file != null,
|
||||
() => {
|
||||
wipeSaves();
|
||||
this.#file = null;
|
||||
},
|
||||
);
|
||||
if (this.#file) {
|
||||
let file = this.#file;
|
||||
addButton(
|
||||
this.#drawpile,
|
||||
`Continue (${turnText})`,
|
||||
new Rect(new Point(128, HEIGHT - 32), new Size(WIDTH - 128, 32)),
|
||||
true,
|
||||
() => {
|
||||
getStateManager().resumeGame(file);
|
||||
this.#done = true;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
addButton(
|
||||
this.#drawpile,
|
||||
`Start New Game`,
|
||||
new Rect(new Point(128, HEIGHT - 32), new Size(WIDTH - 128, 32)),
|
||||
true,
|
||||
() => {
|
||||
getStateManager().startFirstGame();
|
||||
this.#done = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
this.#drawpile.executeOnClick();
|
||||
}
|
||||
|
||||
draw() {
|
||||
this.#drawpile.draw();
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,29 @@
|
||||
import { Sound } from "./sound.ts";
|
||||
import { SaveFileV1 } from "./saveformat.ts";
|
||||
|
||||
export type VNSceneMessage = {
|
||||
type: "message";
|
||||
text: string;
|
||||
sfx?: string;
|
||||
sfx?: Sound;
|
||||
};
|
||||
|
||||
export type VNSceneBasisPart = string | VNSceneMessage;
|
||||
export type VNSceneCallback = {
|
||||
type: "callback";
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
export type VNSceneSaveGameScreen = {
|
||||
type: "saveGameScreen";
|
||||
file: SaveFileV1 | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type VNSceneBasisPart = string | VNSceneMessage | VNSceneCallback;
|
||||
export type VNSceneBasis = VNSceneBasisPart[];
|
||||
export type VNScenePart = VNSceneMessage;
|
||||
export type VNScenePart =
|
||||
| VNSceneMessage
|
||||
| VNSceneCallback
|
||||
| VNSceneSaveGameScreen;
|
||||
export type VNScene = VNScenePart[];
|
||||
|
||||
export function compile(basis: VNSceneBasis): VNScene {
|
||||
|
@ -31,6 +31,7 @@ import {
|
||||
} from "./skills.ts";
|
||||
import { compile, VNSceneBasisPart } from "./vnscene.ts";
|
||||
import { getPlayerProgress } from "./playerprogress.ts";
|
||||
import { sndVnBreath } from "./sounds.ts";
|
||||
|
||||
class WishesTable {
|
||||
#wishes: WishData[];
|
||||
@ -68,7 +69,7 @@ export function getWishes(): WishesTable {
|
||||
const whisper: VNSceneBasisPart = {
|
||||
type: "message",
|
||||
text: "...",
|
||||
sfx: "whisper.mp3",
|
||||
sfx: sndVnBreath,
|
||||
};
|
||||
|
||||
export const celebritySocialite = table.add({
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { Color, Grid, Point, Rect, Size } from "./engine/datatypes.ts";
|
||||
import { DrawPile } from "./drawpile.ts";
|
||||
import { GridArt } from "./gridart.ts";
|
||||
import {
|
||||
BG_CEILING,
|
||||
BG_WALL_OR_UNREVEALED,
|
||||
FG_BOLD,
|
||||
FG_TEXT,
|
||||
} from "./colors.ts";
|
||||
import { C } from "./colors.ts";
|
||||
|
||||
export class World3D {
|
||||
#grid: Grid<Element3D>;
|
||||
@ -27,7 +22,7 @@ export class World3D {
|
||||
|
||||
if (here == null) {
|
||||
drawpile.add(OFFSET_TOP, () => {
|
||||
gridArt.drawCeiling(BG_CEILING);
|
||||
gridArt.drawCeiling(C.BG_CEILING);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -135,6 +130,6 @@ export class Block3D {
|
||||
}
|
||||
|
||||
static standardWall(): Block3D {
|
||||
return new Block3D(FG_BOLD, FG_TEXT, BG_WALL_OR_UNREVEALED);
|
||||
return new Block3D(C.BG_OUTERWALL, C.BG_INNERWALL, C.BG_WALL_OR_UNREVEALED);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
10
vite.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
// vite.config.js
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
minify: false,
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
});
|