Compare commits

...

25 Commits

Author SHA1 Message Date
260422fabe Arrow and vim keys support (#43)
accept arrows and hjkl as movement input too

Cnange opening text to mention these control options

Reviewed-on: #43
Co-authored-by: Kistaro Windrider <kistaro@gmail.com>
Co-committed-by: Kistaro Windrider <kistaro@gmail.com>
2025-02-26 04:16:54 +00:00
a2e09e5237
disable the *other* mapgen debug spew 2025-02-24 21:13:50 -08:00
8260bf8b21 Disable map debug display, use phrase "sleep and save your game" 2025-02-24 20:46:58 -08:00
19b097a0bd Save system: ceremonial PR (#42)
prototype for writing a save

Merge branch 'main' into savesystem

violently read player from file

oops, missed revisions in StateManager

create StateManager from file

autoformat the world

oops, forgot to save the split-up of save.ts

Save on end-of-day, or after endgame.

Putting it here avoids a circular reference problem

Merge branch 'main' into savesystem

Integrate save system

Deal with save corruption correctly

Co-authored-by: Kistaro Windrider <kistaro@gmail.com>
Reviewed-on: #42
Co-authored-by: Nyeogmi <economicsbat@gmail.com>
Co-committed-by: Nyeogmi <economicsbat@gmail.com>
2025-02-25 04:14:02 +00:00
897133f8de Reformat config file 2025-02-24 19:01:12 -08:00
c285c76096 Fix two known bugs (and WebStorm) 2025-02-24 19:00:44 -08:00
7d0e5566f8 Playtester build 2025-02-23 21:12:54 -08:00
02d32266e9 Fix a linter warning 2025-02-23 19:58:38 -08:00
770ff68a62 Dynamic colors 2025-02-23 19:58:00 -08:00
a57cc50803 Put colors in instance variables 2025-02-23 18:37:34 -08:00
f2f20b820e Enhance ending sounds 2025-02-23 17:50:02 -08:00
81f498c804 Fix the numbers 2025-02-23 16:23:54 -08:00
5ab3778074 Restructure level gen a little 2025-02-23 14:50:29 -08:00
18ce5875c5 Additional fixes to thrall art 2025-02-23 13:58:17 -08:00
4616945b12 Increase contrast with BG for character art 2025-02-23 13:55:56 -08:00
b0226d5d4b Finish the NPC dialogue 2025-02-23 13:44:33 -08:00
a7024728ba Add item collection sound 2025-02-23 12:16:14 -08:00
d031a6acbe Various minor fixes to successor system and mixing 2025-02-23 12:09:47 -08:00
9024d67114 Add an opening scene 2025-02-23 11:36:43 -08:00
2923fd0a11 Add sounds to stuff 2025-02-22 23:44:27 -08:00
6ede822d4a Run prettier 2025-02-22 22:51:21 -08:00
9d4a9bc0b1 Use Bhijn's algorithm for corner assist 2025-02-22 22:45:01 -08:00
bccd7661b8 An iteration of the original solution that maybe feels less bad 2025-02-22 22:05:28 -08:00
f1872c74ad Hacky movement issue fix
Maybe Kistaro can do better? This design has the problem that like most
designs that don't move the player to a contact point, walking into a
wall causes vibration.
2025-02-22 22:01:27 -08:00
1ffc0518b2 Ceremonial PR: fix map gen (#39)
improve errors

merge state debug dumper

use detailed debugging in map gen

more distinct wall chars

not all sealed walls are walls. okay

handle negative region IDs

also catches some missed semis

stop using the "dark shade" character for standard walls

now uses inverse bullet for sealed walls and full block otherwise

also show final result with region numbers

fix fencepost error when merging regions

map connectedness checker (floodfill)

check for connectedness in mapgen

add commented-out cheat and test buttons

looks like mapgen is now fixed. here are the buttons I used to test it

autoformat code

Merge branch 'main' into fix-mapgen

Co-authored-by: Kistaro Windrider <kistaro@gmail.com>
Reviewed-on: #39
2025-02-23 05:41:19 +00:00
68 changed files with 1820 additions and 412 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
packaging/gif2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
packaging/gif3.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
packaging/thumbnail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 B

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 168 B

BIN
src/art/sounds/bite.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/collect.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/death.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/dig.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/ending.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/recruit.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/art/sounds/silence.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/sleep.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/vn_bat.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
src/art/sounds/vn_dance.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
src/art/sounds/vn_ghost.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/vn_page.mp3 Normal file

Binary file not shown.

BIN
src/art/sounds/vn_phone.mp3 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 B

After

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 655 B

View File

@ -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)),

View File

@ -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;

View File

@ -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();

View File

@ -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>;

View File

@ -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,
},

View File

@ -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.",
]);

View File

@ -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];

View File

@ -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];
}

View File

@ -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(

View File

@ -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();

View File

@ -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(

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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;

View File

@ -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;
}
}

View File

@ -42,14 +42,14 @@ const names = [
"Thisby",
"Calloway",
"Fenna",
"Lupin",
// "Lupin",
"Finlo",
"Tycho",
"Talmadge",
// others
"Jeff",
"Jon",
"Garrett",
// "Garrett",
"Russell",
"Tyson",
"Gervase",

View File

@ -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
View 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.`,
]);

View File

@ -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,
};
}

View File

@ -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(

View File

@ -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
View 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
View 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;
}

View File

@ -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";

View File

@ -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:

View File

@ -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
View 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
View 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;
}

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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,
];

View File

@ -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",

View File

@ -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();
}
}

View File

@ -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 {

View File

@ -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({

View File

@ -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);
}
}

View File

@ -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
View File

@ -0,0 +1,10 @@
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
base: "",
minify: false,
build: {
target: "esnext",
},
});