Files
dsh-long-skin/src/client/logic.ts
kingOfSoySauce f24aa0841d Initial release
2026-08-14 22:30:15 +08:00

190 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const PREVIEW_MAX_FRAME = 240;
export const MAX_LEVEL = 30;
const LIANG_RANKS = ["小难梁", "牢梁", "梁子", "梁圣", "梁神", "梁祖"] as const;
export interface EffortLike {
id: string;
name: string;
description?: string;
}
export interface Palette {
level: number;
stage: number;
strength: number;
page: string;
base: string;
layer1: string;
layer2: string;
layer3: string;
sidebar: string;
ink: string;
secondary: string;
tertiary: string;
border: string;
accent: string;
accentHover: string;
hover: string;
portraitOpacity: string;
}
type Rgb = readonly [number, number, number];
interface PaletteStop {
at: number;
page: Rgb;
surface: Rgb;
surface2: Rgb;
ink: Rgb;
secondary: Rgb;
accent: Rgb;
portraitOpacity: number;
}
// These are the original calibrator's six stage anchors: pale neutral at the
// low end, progressively smoked surfaces, then charcoal and antique gold.
const STOPS: readonly PaletteStop[] = [
{ at: 0, page: [232, 233, 229], surface: [248, 248, 245], surface2: [238, 239, 235], ink: [23, 24, 22], secondary: [112, 116, 111], accent: [181, 43, 36], portraitOpacity: 0.92 },
{ at: 6, page: [211, 211, 206], surface: [239, 239, 234], surface2: [224, 224, 218], ink: [26, 26, 24], secondary: [101, 101, 96], accent: [181, 43, 36], portraitOpacity: 0.93 },
{ at: 12, page: [171, 168, 162], surface: [211, 208, 201], surface2: [190, 186, 179], ink: [27, 26, 24], secondary: [83, 79, 74], accent: [166, 54, 42], portraitOpacity: 0.94 },
{ at: 18, page: [117, 112, 106], surface: [154, 148, 140], surface2: [128, 122, 115], ink: [24, 22, 20], secondary: [65, 61, 56], accent: [154, 56, 42], portraitOpacity: 0.94 },
{ at: 24, page: [43, 39, 37], surface: [48, 43, 40], surface2: [58, 51, 46], ink: [244, 241, 232], secondary: [184, 180, 169], accent: [181, 92, 54], portraitOpacity: 0.94 },
{ at: 30, page: [17, 17, 17], surface: [30, 27, 25], surface2: [41, 35, 30], ink: [244, 241, 232], secondary: [184, 180, 169], accent: [193, 154, 73], portraitOpacity: 0.94 },
];
export function clampFrame(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(value)));
}
export function frameForEffort(index: number, count: number): number {
if (count <= 1 || !Number.isFinite(index)) return 0;
const safe = Math.min(count - 1, Math.max(0, Math.round(index)));
return Math.round((safe / (count - 1)) * PREVIEW_MAX_FRAME);
}
export function nearestEffortIndex(frame: number, efforts: readonly EffortLike[]): number {
if (efforts.length === 0) return -1;
const safe = clampFrame(frame);
let best = 0;
let distance = Math.abs(safe - frameForEffort(0, efforts.length));
for (let index = 1; index < efforts.length; index += 1) {
const next = Math.abs(safe - frameForEffort(index, efforts.length));
if (next < distance) {
best = index;
distance = next;
}
}
return best;
}
export function selectedEffortIndex(
efforts: readonly EffortLike[],
selectedId?: string,
defaultId?: string,
): number {
const id = selectedId ?? defaultId;
return id === undefined ? -1 : efforts.findIndex((effort) => effort.id === id);
}
export function portraitBlendForLevel(level: number, anchors: readonly number[]) {
if (anchors.length === 0) return { lowerIndex: -1, upperIndex: -1, mix: 0 };
const safeLevel = Number.isFinite(level)
? Math.min(anchors[anchors.length - 1], Math.max(anchors[0], level))
: anchors[0];
let upperIndex = anchors.findIndex((anchor) => anchor >= safeLevel);
if (upperIndex < 0) upperIndex = anchors.length - 1;
const lowerIndex = Math.max(0, upperIndex - (anchors[upperIndex] > safeLevel ? 1 : 0));
const span = anchors[upperIndex] - anchors[lowerIndex];
return {
lowerIndex,
upperIndex,
mix: span === 0 ? 0 : (safeLevel - anchors[lowerIndex]) / span,
};
}
export function liangRankForFrame(rawFrame: number): string {
const level = (clampFrame(rawFrame) / PREVIEW_MAX_FRAME) * MAX_LEVEL;
const index = level >= MAX_LEVEL ? LIANG_RANKS.length - 1 : Math.floor(level / 6);
return LIANG_RANKS[Math.min(LIANG_RANKS.length - 1, Math.max(0, index))];
}
export function indicatorLabel(rawFrame: number, efforts: readonly EffortLike[]): string {
const effort = efforts[nearestEffortIndex(rawFrame, efforts)];
return effort === undefined
? liangRankForFrame(rawFrame)
: `${liangRankForFrame(rawFrame)} · ${effort.name}`;
}
function lerp(a: number, b: number, amount: number): number {
return a + (b - a) * amount;
}
function mix(a: Rgb, b: Rgb, amount: number): Rgb {
return [
Math.round(lerp(a[0], b[0], amount)),
Math.round(lerp(a[1], b[1], amount)),
Math.round(lerp(a[2], b[2], amount)),
];
}
function rgb(value: Rgb, alpha = 1): string {
return alpha === 1
? `rgb(${value[0]} ${value[1]} ${value[2]})`
: `rgb(${value[0]} ${value[1]} ${value[2]} / ${alpha})`;
}
export function paletteForFrame(rawFrame: number): Palette {
const frame = clampFrame(rawFrame);
const level = (frame / PREVIEW_MAX_FRAME) * MAX_LEVEL;
const portraitStage = Math.min(5, Math.floor(level / 6));
const portraitFrom = STOPS[portraitStage];
const portraitTo = STOPS[Math.min(5, portraitStage + 1)];
const portraitAmount = portraitFrom === portraitTo
? 0
: (level - portraitFrom.at) / (portraitTo.at - portraitFrom.at);
// The UI itself is intentionally binary: levels 0–23 keep the native light
// shell; entering the original 梁神/梁祖 region at level 24 switches black/gold.
const dark = level >= 24;
const stage = dark ? 5 : 0;
const ui = dark ? STOPS[5] : STOPS[0];
const page = ui.page;
const surface = ui.surface;
const surface2 = ui.surface2;
const sidebar = mix(page, surface2, 0.25);
const ink = ui.ink;
const secondary = ui.secondary;
// Keep Harness' native blue in the light shell. The original calibrator's
// antique gold only becomes the product accent once the dark region begins.
const accent: Rgb = dark ? [193, 154, 73] : [65, 118, 230];
const accentHover = mix(accent, ink, 0.13);
return {
level,
stage,
strength: level / MAX_LEVEL,
page: rgb(page),
// Keep the shell readable while allowing the right-side portrait to remain
// visibly present. Dense controls use the opaque layer tokens below.
base: rgb(page, dark ? 0.42 : 0.28),
layer1: rgb(surface, 0.94),
layer2: rgb(surface2, 0.96),
layer3: rgb(surface2, 0.99),
sidebar: rgb(sidebar, 0.96),
ink: rgb(ink),
secondary: rgb(secondary),
tertiary: rgb(mix(secondary, page, 0.28)),
border: rgb(ink, dark ? 0.15 : 0.12),
accent: rgb(accent),
accentHover: rgb(accentHover),
hover: rgb(ink, dark ? 0.09 : 0.07),
portraitOpacity: String(lerp(
portraitFrom.portraitOpacity,
portraitTo.portraitOpacity,
portraitAmount,
)),
};
}