feat: кубики/таймер/кисть/спонсоры и задел игровых систем

Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-13 15:37:44 +08:00
parent 3d0474fe97
commit 712086ddca
32 changed files with 1712 additions and 34 deletions
+187
View File
@@ -0,0 +1,187 @@
/** Minimal safe formula DSL for derived stats. */
export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none';
export type FormulaContext = Record<string, number>;
const IDENT = /^(attr|skill|resource|derived|val)\.[A-Za-z_][A-Za-z0-9_]*$/;
function tokenize(input: string): string[] {
const src = input.replace(/\s+/g, '');
const tokens: string[] = [];
let i = 0;
while (i < src.length) {
const ch = src[i]!;
if ('+-*/%(),'.includes(ch)) {
tokens.push(ch);
i += 1;
continue;
}
if (/[0-9.]/.test(ch)) {
let j = i + 1;
while (j < src.length && /[0-9.]/.test(src[j]!)) j += 1;
tokens.push(src.slice(i, j));
i = j;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
let j = i + 1;
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j]!)) j += 1;
tokens.push(src.slice(i, j));
i = j;
continue;
}
throw new Error(`Unexpected character '${ch}' in formula`);
}
return tokens;
}
type Parser = {
tokens: string[];
i: number;
ctx: FormulaContext;
};
function peek(p: Parser): string | undefined {
return p.tokens[p.i];
}
function consume(p: Parser, expected?: string): string {
const t = p.tokens[p.i];
if (t === undefined) throw new Error('Unexpected end of formula');
if (expected !== undefined && t !== expected) throw new Error(`Expected '${expected}', got '${t}'`);
p.i += 1;
return t;
}
function parseExpression(p: Parser): number {
let left = parseTerm(p);
while (peek(p) === '+' || peek(p) === '-') {
const op = consume(p);
const right = parseTerm(p);
left = op === '+' ? left + right : left - right;
}
return left;
}
function parseTerm(p: Parser): number {
let left = parseUnary(p);
while (peek(p) === '*' || peek(p) === '/' || peek(p) === '%') {
const op = consume(p);
const right = parseUnary(p);
if (op === '*') left *= right;
else if (op === '/') left /= right;
else left %= right;
}
return left;
}
function parseUnary(p: Parser): number {
if (peek(p) === '-') {
consume(p);
return -parseUnary(p);
}
if (peek(p) === '+') {
consume(p);
return parseUnary(p);
}
return parsePrimary(p);
}
function parsePrimary(p: Parser): number {
const t = peek(p);
if (t === undefined) throw new Error('Unexpected end of formula');
if (t === '(') {
consume(p, '(');
const v = parseExpression(p);
consume(p, ')');
return v;
}
if (/^[0-9]+(\.[0-9]+)?$/.test(t)) {
consume(p);
return Number(t);
}
if (IDENT.test(t)) {
consume(p);
const v = p.ctx[t];
if (typeof v !== 'number' || Number.isNaN(v)) {
throw new Error(`Unknown or non-numeric identifier '${t}'`);
}
return v;
}
// function call
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && p.tokens[p.i + 1] === '(') {
const name = consume(p);
consume(p, '(');
const args: number[] = [];
if (peek(p) !== ')') {
args.push(parseExpression(p));
while (peek(p) === ',') {
consume(p, ',');
args.push(parseExpression(p));
}
}
consume(p, ')');
return callFn(name, args);
}
throw new Error(`Unexpected token '${t}'`);
}
function callFn(name: string, args: number[]): number {
switch (name) {
case 'floor':
return Math.floor(args[0] ?? 0);
case 'ceil':
return Math.ceil(args[0] ?? 0);
case 'round':
return Math.round(args[0] ?? 0);
case 'abs':
return Math.abs(args[0] ?? 0);
case 'min':
return Math.min(...args);
case 'max':
return Math.max(...args);
case 'clamp': {
const [v = 0, lo = 0, hi = 0] = args;
return Math.min(hi, Math.max(lo, v));
}
default:
throw new Error(`Unknown function '${name}'`);
}
}
export function evaluateFormula(
formula: string,
ctx: FormulaContext,
round: FormulaRound = 'none',
): number {
const p: Parser = { tokens: tokenize(formula), i: 0, ctx };
const value = parseExpression(p);
if (p.i !== p.tokens.length) throw new Error('Trailing tokens in formula');
if (round === 'floor') return Math.floor(value);
if (round === 'ceil') return Math.ceil(value);
if (round === 'round') return Math.round(value);
return value;
}
export function buildFormulaContext(values: {
attributes?: Record<string, number | string | boolean>;
skills?: Record<string, number | string | boolean>;
resources?: Record<string, number>;
derived?: Record<string, number>;
}): FormulaContext {
const ctx: FormulaContext = {};
for (const [k, v] of Object.entries(values.attributes ?? {})) {
if (typeof v === 'number') ctx[`attr.${k}`] = v;
}
for (const [k, v] of Object.entries(values.skills ?? {})) {
if (typeof v === 'number') ctx[`skill.${k}`] = v;
}
for (const [k, v] of Object.entries(values.resources ?? {})) {
ctx[`resource.${k}`] = v;
}
for (const [k, v] of Object.entries(values.derived ?? {})) {
ctx[`derived.${k}`] = v;
}
return ctx;
}