Initial release
This commit is contained in:
189
src/client/logic.ts
Normal file
189
src/client/logic.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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,
|
||||
)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user