chore: init clean tree
This commit is contained in:
156
src/bootsequence/dia.ts
Normal file
156
src/bootsequence/dia.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
type ResolvableText = string | ((answers: Record<string, string>) => string);
|
||||
|
||||
type Question = {
|
||||
t: "q",
|
||||
text: ResolvableText,
|
||||
answers: {
|
||||
text: string,
|
||||
value: string
|
||||
}[],
|
||||
id: string
|
||||
}
|
||||
|
||||
type Dia = {
|
||||
t: "d",
|
||||
text: ResolvableText
|
||||
}
|
||||
|
||||
type Wai = {
|
||||
t: "w",
|
||||
time: number
|
||||
}
|
||||
|
||||
type Fun = {
|
||||
t: "f",
|
||||
f: () => any
|
||||
}
|
||||
|
||||
type chrt = "kris" | "susie" | "ralsei" | "noelle"
|
||||
type desktopt = "hyprland" | "plasma"
|
||||
|
||||
let chr: chrt = "kris";
|
||||
let desktop: desktopt = "hyprland";
|
||||
|
||||
export function setChar(newchr: chrt) {
|
||||
chr = newchr;
|
||||
}
|
||||
|
||||
export function setDesktop(newdesktop: desktopt) {
|
||||
desktop = newdesktop;
|
||||
}
|
||||
|
||||
// TODO: Work on this a bit more
|
||||
export const QUESTIONS: (Question | Dia | Wai | Fun)[] = [
|
||||
{
|
||||
t: "w",
|
||||
time: 4000
|
||||
},
|
||||
{
|
||||
t: "q",
|
||||
id: "char",
|
||||
text: "SELECT THE VESSEL YOU PREFER.",
|
||||
answers: [
|
||||
{
|
||||
text: "RALSEI",
|
||||
value: "ralsei"
|
||||
},
|
||||
{
|
||||
text: "SUSIE",
|
||||
value: "susie"
|
||||
},
|
||||
{
|
||||
text: "KRIS",
|
||||
value: "kris"
|
||||
},
|
||||
{
|
||||
text: "NOELLE",
|
||||
value: "noelle"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
t: "d",
|
||||
text: "YOU HAVE CHOSEN A WONDERFUL FORM."
|
||||
},
|
||||
{
|
||||
t: "d",
|
||||
text: () => `NOW LET US SHAPE ${(["noelle", "susie"]).includes(chr) ? "HER" : chr === "ralsei" ? "HIS" : "THEIR"} MIND AS YOUR OWN.`
|
||||
},
|
||||
{
|
||||
t: "q",
|
||||
id: "desktop",
|
||||
text: () => `WHAT IS ${(["noelle", "susie"]).includes(chr) ? "HER" : chr === "ralsei" ? "HIS" : "THEIR"} FAVORITE DESKTOP ENVIRONMENT?`,
|
||||
answers: [
|
||||
{
|
||||
text: "HYPRLAND",
|
||||
value: "hyprland"
|
||||
},
|
||||
{
|
||||
text: "KDE",
|
||||
value: "plasma"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
t: "d",
|
||||
text: () => `${desktop === "hyprland" ? "HYPRLAND" : "KDE"}, INTERESTING CHOICE..`
|
||||
},
|
||||
{
|
||||
t: "q",
|
||||
id: "color",
|
||||
text: "YOUR FAVORITE COLOR PALETTE?",
|
||||
answers: [
|
||||
{
|
||||
text: "LATTE",
|
||||
value: "latte"
|
||||
},
|
||||
{
|
||||
text: "FRAPPE",
|
||||
value: "frappe"
|
||||
},
|
||||
{
|
||||
text: "MACCHIATO",
|
||||
value: "macchiato"
|
||||
},
|
||||
{
|
||||
text: "MOCHA",
|
||||
value: "mocha"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
t: "q",
|
||||
id: "gift",
|
||||
text: () => `PLEASE GIVE ${(["noelle", "susie"]).includes(chr) ? "HER" : chr === "ralsei" ? "HIM" : "THEM"} A GIFT.`,
|
||||
answers: [
|
||||
{
|
||||
text: "KINDNESS",
|
||||
value: "kindness"
|
||||
},
|
||||
{
|
||||
text: "MIND",
|
||||
value: "mind"
|
||||
},
|
||||
{
|
||||
text: "AMBITION",
|
||||
value: "ambition"
|
||||
},
|
||||
{
|
||||
text: "BRAVERY",
|
||||
value: "bravery"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
t: "d",
|
||||
text: "THANK YOU FOR YOUR TIME."
|
||||
},
|
||||
{
|
||||
t: "d",
|
||||
text: () => `YOUR WONDERFUL CREATION, ${chr.toUpperCase()}`
|
||||
},
|
||||
{
|
||||
t: "d",
|
||||
text: "WILL NOW BE"
|
||||
}
|
||||
]
|
||||
216
src/bootsequence/font.ts
Normal file
216
src/bootsequence/font.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import fs from "fs";
|
||||
import { type CanvasRenderingContext2D, type Image } from "@napi-rs/canvas";
|
||||
import { loadImageAsset, resolveAssetPath } from "../renderer/assets";
|
||||
|
||||
type Glyph = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
shift: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type GlyphMap = Map<number, Glyph>;
|
||||
|
||||
export type BitmapFont = {
|
||||
atlas: Image;
|
||||
glyphs: GlyphMap;
|
||||
lineHeight: number;
|
||||
};
|
||||
|
||||
function loadGlyphs(csvRelativePath: string): GlyphMap {
|
||||
const csvPath = resolveAssetPath(csvRelativePath);
|
||||
const raw = fs.readFileSync(csvPath, "utf8");
|
||||
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
const glyphs: GlyphMap = new Map();
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const parts = lines[i]!.split(";");
|
||||
if (parts.length < 7) continue;
|
||||
|
||||
const [
|
||||
charCodeStr,
|
||||
xStr,
|
||||
yStr,
|
||||
wStr,
|
||||
hStr,
|
||||
shiftStr,
|
||||
offsetStr
|
||||
] = parts;
|
||||
|
||||
const code = Number(charCodeStr);
|
||||
const glyph: Glyph = {
|
||||
x: Number(xStr),
|
||||
y: Number(yStr),
|
||||
w: Number(wStr),
|
||||
h: Number(hStr),
|
||||
shift: Number(shiftStr),
|
||||
offset: Number(offsetStr)
|
||||
};
|
||||
|
||||
if (Number.isFinite(code)) glyphs.set(code, glyph);
|
||||
}
|
||||
|
||||
return glyphs;
|
||||
}
|
||||
|
||||
function computeLineHeight(glyphs: GlyphMap): number {
|
||||
let maxHeight = 0;
|
||||
for (const glyph of glyphs.values()) {
|
||||
if (glyph.h > maxHeight) maxHeight = glyph.h;
|
||||
}
|
||||
return maxHeight + 4;
|
||||
}
|
||||
|
||||
export async function loadBitmapFont(
|
||||
atlasRelativePath = "font/fnt_main.png",
|
||||
glyphsRelativePath = "font/glyphs_fnt_main.csv"
|
||||
): Promise<BitmapFont> {
|
||||
const glyphs = loadGlyphs(glyphsRelativePath);
|
||||
const lineHeight = computeLineHeight(glyphs);
|
||||
const atlas = await loadImageAsset(atlasRelativePath);
|
||||
return { atlas, glyphs, lineHeight };
|
||||
}
|
||||
|
||||
type DrawOptions = {
|
||||
align?: "left" | "center";
|
||||
color?: string;
|
||||
alpha?: number;
|
||||
scale?: number; // <— TEXT SCALE
|
||||
};
|
||||
|
||||
function normScale(scale: number | undefined): number {
|
||||
const s = scale ?? 1;
|
||||
return Number.isFinite(s) && s > 0 ? s : 1;
|
||||
}
|
||||
|
||||
export function measureTextWidth(
|
||||
text: string,
|
||||
font: BitmapFont,
|
||||
options: Pick<DrawOptions, "scale"> = {}
|
||||
): number {
|
||||
const scale = normScale(options.scale);
|
||||
let width = 0;
|
||||
for (const ch of text) {
|
||||
const glyph = font.glyphs.get(ch.codePointAt(0) ?? 0);
|
||||
width += (glyph?.shift ?? 0) * scale;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
export function drawBitmapText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
font: BitmapFont,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
options: DrawOptions = {}
|
||||
): void {
|
||||
const { atlas, glyphs, lineHeight } = font;
|
||||
const align = options.align ?? "left";
|
||||
const color = options.color;
|
||||
const alpha = options.alpha ?? 1;
|
||||
const scale = normScale(options.scale);
|
||||
|
||||
let cursor = x;
|
||||
if (align === "center") {
|
||||
cursor = x - measureTextWidth(text, font, { scale }) / 2;
|
||||
}
|
||||
|
||||
const previousAlpha = ctx.globalAlpha;
|
||||
ctx.globalAlpha = previousAlpha * alpha;
|
||||
|
||||
const startX = cursor;
|
||||
|
||||
for (const ch of text) {
|
||||
const glyph = glyphs.get(ch.codePointAt(0) ?? 0);
|
||||
if (!glyph) {
|
||||
cursor += 8 * scale;
|
||||
continue;
|
||||
}
|
||||
|
||||
(ctx as any).drawImage(
|
||||
atlas as any,
|
||||
glyph.x,
|
||||
glyph.y,
|
||||
glyph.w,
|
||||
glyph.h,
|
||||
cursor + glyph.offset * scale,
|
||||
y,
|
||||
glyph.w * scale,
|
||||
glyph.h * scale
|
||||
);
|
||||
|
||||
cursor += glyph.shift * scale;
|
||||
}
|
||||
|
||||
if (color && color.toLowerCase() !== "white") {
|
||||
const width = cursor - startX;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = previousAlpha * alpha;
|
||||
ctx.globalCompositeOperation = "source-atop";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(startX, y, width, lineHeight * scale);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = previousAlpha;
|
||||
}
|
||||
|
||||
export function drawBitmapTextPerGlyph(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
font: BitmapFont,
|
||||
text: string,
|
||||
startX: number,
|
||||
y: number,
|
||||
options: DrawOptions = {}
|
||||
): void {
|
||||
const { atlas, glyphs } = font;
|
||||
const color = options.color;
|
||||
const alpha = options.alpha ?? 1;
|
||||
const scale = normScale(options.scale);
|
||||
|
||||
let cursor = startX;
|
||||
|
||||
const previousAlpha = ctx.globalAlpha;
|
||||
ctx.globalAlpha = previousAlpha * alpha;
|
||||
|
||||
for (const ch of text) {
|
||||
const glyph = glyphs.get(ch.codePointAt(0) ?? 0);
|
||||
if (!glyph) {
|
||||
cursor += 8 * scale;
|
||||
continue;
|
||||
}
|
||||
|
||||
(ctx as any).drawImage(
|
||||
atlas as any,
|
||||
glyph.x,
|
||||
glyph.y,
|
||||
glyph.w,
|
||||
glyph.h,
|
||||
cursor + glyph.offset * scale,
|
||||
y,
|
||||
glyph.w * scale,
|
||||
glyph.h * scale
|
||||
);
|
||||
|
||||
if (color && color.toLowerCase() !== "white") {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = previousAlpha * alpha;
|
||||
ctx.globalCompositeOperation = "source-atop";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(
|
||||
cursor + glyph.offset * scale,
|
||||
y,
|
||||
glyph.w * scale,
|
||||
glyph.h * scale
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
cursor += glyph.shift * scale;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = previousAlpha;
|
||||
}
|
||||
466
src/bootsequence/questions.ts
Normal file
466
src/bootsequence/questions.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import sdl from "@kmamal/sdl";
|
||||
import { type CanvasRenderingContext2D, type Image } from "@napi-rs/canvas";
|
||||
|
||||
import { loadImageAsset } from "../renderer/assets";
|
||||
import { type BitmapFont, drawBitmapTextPerGlyph, loadBitmapFont, measureTextWidth } from "./font";
|
||||
import { QUESTIONS, setChar, setDesktop } from "./dia";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
type ResolvableText = string | ((answers: Record<string, string>) => string);
|
||||
type BootsequenceAnswerKey = keyof BootsequenceAnswers;
|
||||
|
||||
type QuestionAnswer = {
|
||||
text: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type QuestionEntry = {
|
||||
t: "q";
|
||||
id: string;
|
||||
text: ResolvableText;
|
||||
answers: QuestionAnswer[];
|
||||
};
|
||||
|
||||
type DialogueEntry = {
|
||||
t: "d";
|
||||
text: ResolvableText;
|
||||
};
|
||||
|
||||
type FunctionEntry = {
|
||||
t: "f",
|
||||
f: () => any
|
||||
}
|
||||
|
||||
type WaitEntry = {
|
||||
t: "w";
|
||||
time: number;
|
||||
};
|
||||
|
||||
type SequenceEntry = QuestionEntry | DialogueEntry | WaitEntry | FunctionEntry;
|
||||
|
||||
const TYPEWRITER_SPEED = 16; // chars/s
|
||||
const DIALOGUE_HOLD_MS = 1200;
|
||||
const HEART_SCALE = 1.1;
|
||||
const TYPEWRITER_DISABLED = false;
|
||||
const ANSWER_FADE_MS = 220;
|
||||
const BLUR_OFFSETS = [
|
||||
[-1, 0],
|
||||
[1, 0],
|
||||
[0, -1],
|
||||
[0, 1]
|
||||
] as const;
|
||||
|
||||
type KeyInput = {
|
||||
key: string | null;
|
||||
scancode: number;
|
||||
ctrl: number;
|
||||
shift: number;
|
||||
alt: number;
|
||||
super: number;
|
||||
};
|
||||
|
||||
import type { BootsequenceAnswers } from "../types";
|
||||
|
||||
export type BootSequenceUI = {
|
||||
update: (deltaMs: number) => void;
|
||||
render: (ctx: CanvasRenderingContext2D) => void;
|
||||
handleKey: (input: KeyInput) => void;
|
||||
isFinished: () => boolean;
|
||||
getAnswers: () => BootsequenceAnswers;
|
||||
};
|
||||
|
||||
function wrapLines(text: string, font: BitmapFont, maxWidth: number): string[] {
|
||||
const tokens = text.split(/(\s+)/);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const token of tokens) {
|
||||
const next = current + token;
|
||||
if (measureTextWidth(next.trimEnd(), font) <= maxWidth) {
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
if (current.trim().length > 0) {
|
||||
lines.push(current.trimEnd());
|
||||
}
|
||||
current = token.trimStart();
|
||||
}
|
||||
|
||||
if (current.trim().length > 0) {
|
||||
lines.push(current.trimEnd());
|
||||
}
|
||||
|
||||
if (lines.length === 0) return [text];
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function createBootSequenceUI(
|
||||
baseWidth: number,
|
||||
baseHeight: number
|
||||
): Promise<BootSequenceUI> {
|
||||
const questionFont = await loadBitmapFont();
|
||||
const answerFont = await loadBitmapFont();
|
||||
const heart = await loadImageAsset("IMAGE_SOUL_BLUR_0.png");
|
||||
const CHARACTER_IDS = ["ralsei", "susie", "kris", "noelle"] as const;
|
||||
type CharacterId = (typeof CHARACTER_IDS)[number];
|
||||
const characterSprites: Record<CharacterId, Image> = {
|
||||
ralsei: await loadImageAsset("chr/ralsei.png"),
|
||||
susie: await loadImageAsset("chr/susie.png"),
|
||||
kris: await loadImageAsset("chr/kris.png"),
|
||||
noelle: await loadImageAsset("chr/noelle.png")
|
||||
};
|
||||
const isCharacterId = (value: string | undefined): value is CharacterId =>
|
||||
CHARACTER_IDS.includes(value as CharacterId);
|
||||
|
||||
let currentIndex = 0;
|
||||
let visibleChars = 0;
|
||||
let selection = 0;
|
||||
let finished = false;
|
||||
const answers: BootsequenceAnswers = {
|
||||
char: "",
|
||||
desktop: "",
|
||||
color: "",
|
||||
gift: ""
|
||||
};
|
||||
let dialogueHold = 0;
|
||||
let loggedCompletion = false;
|
||||
const textCache = new WeakMap<SequenceEntry, string>();
|
||||
const graphemeCache = new WeakMap<SequenceEntry, string[]>();
|
||||
let waitElapsed = 0;
|
||||
let answerAlpha = 0;
|
||||
const lineCache = new WeakMap<SequenceEntry, string[]>();
|
||||
|
||||
const currentEntry = (): SequenceEntry | FunctionEntry | undefined => QUESTIONS[currentIndex];
|
||||
const resolveText = (entry: QuestionEntry | DialogueEntry): string => {
|
||||
const cached = textCache.get(entry);
|
||||
if (cached) return cached;
|
||||
const rawText = entry.text;
|
||||
const resolved = typeof rawText === "function" ? rawText(answers) : rawText;
|
||||
textCache.set(entry, resolved);
|
||||
return resolved;
|
||||
};
|
||||
const graphemesForEntry = (entry: SequenceEntry | undefined): string[] => {
|
||||
if (!entry) return [];
|
||||
if (entry.t === "w") return [];
|
||||
if (entry.t === "f") return [];
|
||||
const cached = graphemeCache.get(entry);
|
||||
if (cached) return cached;
|
||||
const graphemes = Array.from(resolveText(entry));
|
||||
graphemeCache.set(entry, graphemes);
|
||||
return graphemes;
|
||||
};
|
||||
const linesForEntry = (entry: SequenceEntry): string[] => {
|
||||
if (entry.t === "w") return [];
|
||||
if (entry.t === "f") return [];
|
||||
const cached = lineCache.get(entry);
|
||||
if (cached) return cached;
|
||||
const lines = wrapLines(resolveText(entry), questionFont, baseWidth * 0.9);
|
||||
lineCache.set(entry, lines);
|
||||
return lines;
|
||||
};
|
||||
const resetForEntry = () => {
|
||||
visibleChars = 0;
|
||||
selection = 0;
|
||||
dialogueHold = 0;
|
||||
waitElapsed = 0;
|
||||
answerAlpha = 0;
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
currentIndex += 1;
|
||||
if (currentIndex >= QUESTIONS.length) {
|
||||
finished = true;
|
||||
} else {
|
||||
resetForEntry();
|
||||
}
|
||||
};
|
||||
|
||||
const skipTypewriter = () => {
|
||||
const entry = currentEntry();
|
||||
if (!entry) return;
|
||||
if (entry.t === "w") {
|
||||
waitElapsed = entry.time;
|
||||
return;
|
||||
}
|
||||
if (entry.t === "f") {
|
||||
entry.f();
|
||||
return;
|
||||
}
|
||||
visibleChars = graphemesForEntry(entry).length;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const entry = currentEntry();
|
||||
if (!entry) return;
|
||||
if (entry.t === "f") {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
const fullyRevealed =
|
||||
entry.t === "w" ? waitElapsed >= entry.time : visibleChars >= graphemesForEntry(entry).length;
|
||||
if (!fullyRevealed) {
|
||||
skipTypewriter();
|
||||
return;
|
||||
}
|
||||
if (entry.t === "d") {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
if (entry.t === "w") {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
const picked = entry.answers[selection];
|
||||
if (picked) {
|
||||
if (isAnswerKey(entry.id)) {
|
||||
answers[entry.id] = picked.value;
|
||||
}
|
||||
if (entry.id === "char" && isCharacterId(picked.value)) {
|
||||
setChar(picked.value);
|
||||
}
|
||||
if (entry.id === "desktop") {
|
||||
setDesktop(picked.value as any);
|
||||
}
|
||||
console.debug(`[debug] [bootsequence/questions] answer ${entry.id}: ${picked.value} (${picked.text})`);
|
||||
}
|
||||
advance();
|
||||
};
|
||||
|
||||
const handleMove = (dir: -1 | 1) => {
|
||||
const entry = currentEntry();
|
||||
if (!entry || entry.t !== "q") return;
|
||||
const fullyRevealed = visibleChars >= graphemesForEntry(entry).length;
|
||||
if (!fullyRevealed) return;
|
||||
const next = (selection + dir + entry.answers.length) % entry.answers.length;
|
||||
selection = next;
|
||||
};
|
||||
|
||||
const update = (deltaMs: number) => {
|
||||
if (finished) {
|
||||
console.debug("[debug] [bootsequence/questions] finish", deltaMs, finished, loggedCompletion)
|
||||
if (!loggedCompletion) {
|
||||
loggedCompletion = true;
|
||||
console.info("[debug] [bootsequence/questions] finished questions", answers);
|
||||
writeFileSync(join(homedir(), ".deltaboot.json"), JSON.stringify(answers))
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = currentEntry();
|
||||
if (!entry) return;
|
||||
if (entry.t === "w") {
|
||||
waitElapsed += deltaMs;
|
||||
if (waitElapsed >= entry.time) advance();
|
||||
return;
|
||||
}
|
||||
if (entry.t === "f") {
|
||||
entry.f();
|
||||
return;
|
||||
}
|
||||
const totalGraphemes = graphemesForEntry(entry).length;
|
||||
if (TYPEWRITER_DISABLED) {
|
||||
visibleChars = totalGraphemes;
|
||||
} else {
|
||||
const step = (deltaMs / 1000) * TYPEWRITER_SPEED;
|
||||
visibleChars = Math.min(totalGraphemes, visibleChars + step);
|
||||
}
|
||||
const fullyRevealed = visibleChars >= totalGraphemes;
|
||||
if (entry.t === "d" && fullyRevealed) {
|
||||
dialogueHold += deltaMs;
|
||||
if (dialogueHold >= DIALOGUE_HOLD_MS) {
|
||||
advance();
|
||||
}
|
||||
}
|
||||
const targetAlpha =
|
||||
entry.t === "q" && fullyRevealed
|
||||
? 1
|
||||
: 0;
|
||||
const delta = deltaMs / ANSWER_FADE_MS;
|
||||
if (targetAlpha > answerAlpha) {
|
||||
answerAlpha = Math.min(targetAlpha, answerAlpha + delta);
|
||||
} else {
|
||||
answerAlpha = Math.max(targetAlpha, answerAlpha - delta);
|
||||
}
|
||||
};
|
||||
|
||||
const renderQuestionText = (ctx: CanvasRenderingContext2D, entry: SequenceEntry) => {
|
||||
const graphemes = graphemesForEntry(entry);
|
||||
const visibleCount = Math.floor(visibleChars);
|
||||
const linesFull = linesForEntry(entry);
|
||||
let remaining = visibleCount;
|
||||
const startX = baseWidth * 0.08;
|
||||
const startY = baseHeight * 0.04;
|
||||
|
||||
for (let i = 0; i < linesFull.length; i++) {
|
||||
const fullLine = linesFull[i] ?? "";
|
||||
const lineGraphemes = Array.from(fullLine);
|
||||
const take = Math.max(0, Math.min(lineGraphemes.length, remaining));
|
||||
remaining = Math.max(0, remaining - take);
|
||||
const line = lineGraphemes.slice(0, take).join("");
|
||||
const y = startY + i * questionFont.lineHeight;
|
||||
|
||||
let cursor = startX;
|
||||
for (const ch of line) {
|
||||
const glyph = questionFont.glyphs.get(ch.codePointAt(0) ?? 0);
|
||||
const glyphWidth = glyph?.shift ?? 8;
|
||||
const drawX = cursor + (glyph?.offset ?? 0);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.3;
|
||||
for (const [ox, oy] of BLUR_OFFSETS) {
|
||||
drawBitmapTextPerGlyph(ctx, questionFont, ch, (drawX + ox), (y + oy) - 15, { align: "left" });
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
drawBitmapTextPerGlyph(ctx, questionFont, ch, drawX, y - 15, { align: "left" });
|
||||
|
||||
cursor += glyphWidth;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderAnswers = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
answersList: QuestionAnswer[],
|
||||
visible: boolean
|
||||
) => {
|
||||
if (!visible && answerAlpha <= 0) return;
|
||||
const startX = baseWidth * 0.28;
|
||||
const startY = baseHeight * 0.45;
|
||||
const alpha = answerAlpha;
|
||||
for (let i = 0; i < answersList.length; i++) {
|
||||
const answer = answersList[i]!;
|
||||
const y = startY + i * answerFont.lineHeight * 1.1;
|
||||
const isActive = i === selection;
|
||||
const color = "white";
|
||||
let cursor = startX;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
for (const ch of answer.text) {
|
||||
const glyph = answerFont.glyphs.get(ch.codePointAt(0) ?? 0);
|
||||
const glyphWidth = glyph?.shift ?? 8;
|
||||
const drawX = cursor + (glyph?.offset ?? 0);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.3;
|
||||
for (const [ox, oy] of BLUR_OFFSETS) {
|
||||
drawBitmapTextPerGlyph(ctx, answerFont, ch, (drawX + ox) - 30, y + oy, { align: "left" });
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
drawBitmapTextPerGlyph(ctx, answerFont, ch, drawX - 30, y, { align: "left" });
|
||||
cursor += glyphWidth;
|
||||
}
|
||||
ctx.restore();
|
||||
if (isActive) {
|
||||
const heartX = startX - heart.width * HEART_SCALE - 12;
|
||||
const heartY = y - heart.height * HEART_SCALE * 0.2;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
drawHeart(ctx, heart, heartX - 25, heartY + 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderDialogue = (ctx: CanvasRenderingContext2D, entry: DialogueEntry) => {
|
||||
renderQuestionText(ctx, entry);
|
||||
};
|
||||
|
||||
const renderCharacterPreview = (ctx: CanvasRenderingContext2D, character: CharacterId) => {
|
||||
const sprite = characterSprites[character];
|
||||
if (!sprite) return;
|
||||
const maxWidth = baseWidth * 0.35;
|
||||
const maxHeight = baseHeight * 0.55;
|
||||
const scale = Math.min(2, Math.min(maxWidth / sprite.width, maxHeight / sprite.height));
|
||||
const drawWidth = sprite.width * scale;
|
||||
const drawHeight = sprite.height * scale;
|
||||
const drawX = (baseWidth - drawWidth) / 2;
|
||||
const drawY = (baseHeight - drawHeight) / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
(ctx as any).drawImage(sprite, drawX + 30, drawY + 30, drawWidth, drawHeight);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const render = (ctx: CanvasRenderingContext2D) => {
|
||||
if (finished) return;
|
||||
const entry = currentEntry();
|
||||
if (!entry) return;
|
||||
const selectedChar = answers["char"];
|
||||
const showCharacter =
|
||||
entry.t === "q" && entry.id === "char" && visibleChars >= graphemesForEntry(entry).length
|
||||
? entry.answers[selection]?.value
|
||||
: selectedChar;
|
||||
if (isCharacterId(showCharacter)) {
|
||||
renderCharacterPreview(ctx, showCharacter);
|
||||
}
|
||||
if (entry.t === "w") return;
|
||||
if (entry.t === "f") return;
|
||||
if (entry.t === "d") {
|
||||
renderDialogue(ctx, entry);
|
||||
} else {
|
||||
renderQuestionText(ctx, entry);
|
||||
const fullyRevealed = visibleChars >= graphemesForEntry(entry).length;
|
||||
renderAnswers(ctx, entry.answers, fullyRevealed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKey = (input: KeyInput) => {
|
||||
if (finished) return;
|
||||
const key = (input.key ?? "").toLowerCase();
|
||||
const sc = input.scancode;
|
||||
const ctrlHeld = input.ctrl > 0 || key === "control" || key === "ctrl";
|
||||
|
||||
if (ctrlHeld) {
|
||||
skipTypewriter();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
key === "arrowup" ||
|
||||
key === "up" ||
|
||||
sc === sdl.keyboard.SCANCODE.UP
|
||||
) {
|
||||
handleMove(-1);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key === "arrowdown" ||
|
||||
key === "down" ||
|
||||
sc === sdl.keyboard.SCANCODE.DOWN
|
||||
) {
|
||||
handleMove(1);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key === "enter" ||
|
||||
key === "return" ||
|
||||
key === "z" ||
|
||||
sc === sdl.keyboard.SCANCODE.RETURN ||
|
||||
sc === sdl.keyboard.SCANCODE.SPACE
|
||||
) {
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
update,
|
||||
render,
|
||||
handleKey,
|
||||
isFinished: () => finished,
|
||||
getAnswers: () => ({ ...answers })
|
||||
};
|
||||
}
|
||||
|
||||
function isAnswerKey(value: string): value is BootsequenceAnswerKey {
|
||||
return value === "char" || value === "desktop" || value === "color" || value === "gift";
|
||||
}
|
||||
|
||||
function drawHeart(ctx: CanvasRenderingContext2D, heart: Image, x: number, y: number): void {
|
||||
ctx.save();
|
||||
(ctx as any).drawImage(heart, x, y, heart.width * HEART_SCALE, heart.height * HEART_SCALE);
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user