chore: init clean tree

This commit is contained in:
2025-12-17 23:19:04 +02:00
commit 01d96d3200
45 changed files with 4152 additions and 0 deletions

156
src/bootsequence/dia.ts Normal file
View 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
View 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;
}

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