sfdemo / js /runtime.js
HelloSun's picture
Fix CPU fight round, KO and match end flow
3250fa0 verified
Raw History Blame Contribute Delete
17.9 kB
// Browser-side fighting core driven by the upstream KFM .cmd/.cns/.air/.sff data.
// The parser intentionally implements the common ground/air combat slice used by KFM.
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const num = (text, fallback = 0) => { const n = parseFloat(String(text ?? '').split(',')[0]); return Number.isFinite(n) ? n : fallback; };
const pair = (text, fallback = [0, 0]) => text ? text.split(',').map((x, i) => num(x, fallback[i] || 0)) : fallback;
export function parseCns(text) {
const states = new Map(); let id = null, state = null, controller = null;
const finishController = () => {
if (id === null || !state || !controller) return;
state.controllers.push(controller);
if (controller.type === 'hitdef') {
const get = k => controller.values.get(k);
state.hitdefs.push({
trigger: controller.values.get('trigger1') || 'time = 0',
attr: get('attr') || 'S, NA', damage: num(get('damage')),
hitflag: (get('hitflag') || 'MAF').toUpperCase(), guardflag: (get('guardflag') || 'MA').toUpperCase(),
ground: pair(get('ground.velocity')).length < 2 ? [...pair(get('ground.velocity')), 0] : pair(get('ground.velocity')),
air: pair(get('air.velocity')).length < 2 ? [...pair(get('air.velocity')), 0] : pair(get('air.velocity')),
pause: pair(get('pausetime'), [8, 8])[0], hitTime: num(get('ground.hittime'), 12),
fall: /1|true/i.test(get('fall') || ''),
hitId: state.hitdefs.length,
});
}
controller = null;
};
const finishState = () => { finishController(); if (id !== null && state) states.set(id, state); };
for (const raw of text.split(/\r?\n/)) {
const line = raw.split(';')[0].trim(); if (!line) continue;
let m = line.match(/^\[\s*Statedef\s+(-?\d+)\s*\]/i);
if (m) { finishState(); id = +m[1]; state = { id, values: new Map(), hitdefs: [], controllers: [] }; controller = null; continue; }
if (id === null) continue;
m = line.match(/^\[\s*State\s+(-?\d+)\s*,/i);
if (m) {
finishController();
controller = { type: '', values: new Map() };
continue;
}
const eq = line.indexOf('='); if (eq < 0) continue;
const key = line.slice(0, eq).trim().toLowerCase(), value = line.slice(eq + 1).trim();
if (controller) { if (key === 'type') controller.type = value.toLowerCase(); controller.values.set(key, value); }
else state.values.set(key, value);
}
finishState();
return states;
}
export function parseCmdEntries(text, commands) {
const nameToCommand = new Map(commands.map(c => [c.name, c.command]));
const entries = []; let inEntry = false, entry = null, inStateMinusOne = false;
const save = () => {
if (!entry || entry.type !== 'changestate' || entry.value === undefined) return;
const cmdName = entry.triggers.find(x => x.kind === '=')?.name || entry.all.find(x => x.kind === '=')?.name;
if (cmdName) entries.push({ state: Number(entry.value), command: cmdName, motion: nameToCommand.get(cmdName) || cmdName,
blockedByDown: entry.all.some(x => x.kind === '!=' && x.name === 'holddown'),
requiresDown: entry.all.some(x => x.kind === '=' && x.name === 'holddown'),
requiresState: entry.all.find(x => x.stateType)?.stateType || entry.triggers.find(x => x.stateType)?.stateType || '',
requiresCtrl: entry.triggers.some(x => x.kind === 'ctrl'),
});
};
for (const raw of text.split(/\r?\n/)) {
const line = raw.split(';')[0].trim(); if (!line) continue;
const sec = line.match(/^\[\s*([^\]]+)\s*\]/);
if (sec) {
save(); const title = sec[1].trim();
if (/^statedef\s+-?\d+$/i.test(title)) inStateMinusOne = /^statedef\s+-1$/i.test(title);
inEntry = inStateMinusOne && /^state\s+-1\s*,/i.test(title);
entry = inEntry ? { type: '', all: [], triggers: [] } : null;
continue;
}
if (!inEntry || !entry) continue;
const m = line.match(/^([^=]+)=(.*)$/); if (!m) continue;
const key = m[1].trim().toLowerCase(), val = m[2].trim().replace(/^"|"$/g, '');
if (key === 'type') entry.type = val.toLowerCase();
else if (key === 'value') entry.value = val;
else if (key.startsWith('triggerall')) {
const c = val.match(/command\s*(=|!=)\s*"?([\w]+)"?/i); if (c) entry.all.push({ kind: c[1], name: c[2] });
const st = val.match(/statetype\s*=\s*([SCA])/i); if (st) entry.all.push({ stateType: st[1].toUpperCase() });
if (/\bctrl\s*=\s*1\b/i.test(val)) entry.all.push({ kind: 'ctrl' });
} else if (/^trigger\d+$/.test(key)) {
const c = val.match(/command\s*(=|!=)\s*"?([\w]+)"?/i); if (c) entry.triggers.push({ kind: c[1], name: c[2] });
const st = val.match(/statetype\s*=\s*([SCA])/i); if (st) entry.triggers.push({ stateType: st[1].toUpperCase() });
if (/\bctrl\s*=\s*1\b/i.test(val)) entry.triggers.push({ kind: 'ctrl' });
}
}
save();
return entries;
}
export class Fighter {
constructor(assets, x, facing, tint, player = false) {
this.assets = assets; this.name = assets.name; this.player = player;
this.x = x; this.y = 0; this.vx = 0; this.vy = 0; this.facing = facing;
this.life = this.maxLife = assets.stats.life; this.tint = tint;
this.state = 0; this.action = 0; this.stateFrame = 0; this.actionFrame = 0; this.elemTick = 0;
this.hitstun = 0; this.hit = null; this.hitDefs = []; this.hitUsed = false; this.hurtFlash = 0; this.ctrl = true;
this.hitPause = 0;
this.grounded = true; this.crouch = false; this.airJumps = 0; this.dirHistory = [];
this.aiWait = 0; this.lastAI = 0;
this.prevKeys = new Set();
this.roundsWon = 0;
this.frame = this.currentFrame();
}
setAction(action, state = this.state) {
this.action = action; this.state = state; this.stateFrame = 0; this.actionFrame = 0; this.elemTick = 0;
this.hit = null; this.hitDefs = []; this.hitUsed = false;
this.animAge = 0;
const s = this.assets.states.get(state);
this.ctrl = s ? /1|true/i.test(s.values.get('ctrl') || '0') : [0, 11, 20, 21, 40, 41, 42, 43, 47].includes(state);
this.hitDefs = [];
if (s) {
const powerAdd = num(s.values.get('poweradd'));
if (powerAdd) this.power = clamp(this.power + powerAdd, 0, 3000);
const anim = Number(s.values.get('anim') || action);
this.action = Number.isFinite(anim) ? anim : action;
if (s.values.has('velset')) { const vel = pair(s.values.get('velset'), [this.vx, this.vy]); this.vx = vel[0]; this.vy = vel[1]; }
this.hitDefs = s.hitdefs.map(hd => this.prepareHitDef(hd));
this.hit = this.hitDefs[0] || null;
}
this.frame = this.currentFrame();
}
resetRound() {
this.life = this.maxLife;
this.x = this.player ? -68 : 68;
this.y = this.vx = this.vy = 0;
this.facing = this.player ? 1 : -1;
this.state = 0; this.stateFrame = this.actionFrame = this.elemTick = 0;
this.hitstun = this.hitPause = this.hurtFlash = 0;
this.ctrl = true; this.grounded = true; this.crouch = false;
this.hit = null; this.hitDefs = []; this.prevKeys.clear(); this.dirHistory.length = 0;
this._power = 0;
this.setAction(0, 0);
}
resetMatch() { this.roundsWon = 0; this.resetRound(); }
currentFrame() {
const a = this.assets.actions.get(this.action);
if (!a?.frames.length) return null;
return a.frames[Math.min(this.actionFrame, a.frames.length - 1)];
}
prepareHitDef(hd) {
const trigger = hd.trigger.match(/animelem\s*=\s*(\d+)/i);
const time = hd.trigger.match(/time\s*=\s*(\d+)/i);
const range = hd.trigger.match(/p2bodydist\s+x\s*([<>]=?)\s*(-?\d+)/i);
return { ...hd, activeElem: trigger ? +trigger[1] : null, activeTime: time ? +time[1] : null,
distanceCondition: range ? { op: range[1], value: +range[2] } : null, hitUsed: false, active: false };
}
update(keys, foe, match) {
this.stateFrame++;
if (this.hitPause > 0) { this.hitPause--; return; }
this.animAge++;
const cpu = keys === null;
if (cpu && !this.hitstun) keys = this.aiKeys(foe);
if (!keys) keys = new Set();
const currentDirection = this.direction(keys);
if (this.dirHistory.at(-1)?.dir !== currentDirection) {
this.dirHistory.push({ dir: currentDirection, tick: this.stateFrame });
if (this.dirHistory.length > 30) this.dirHistory.shift();
}
if (this.hitstun > 0) this.hitstun--;
if (this.hurtFlash > 0) this.hurtFlash--;
if (this.hitstun === 0 && (this.state === 5000 || this.state === 5030)) this.setAction(0, 0);
const pressed = new Set([...keys].filter(key => !this.prevKeys.has(key)));
this.prevKeys = new Set(keys);
if (this.hitstun === 0 && this.ctrl) this.handleInput(keys, pressed, foe);
else if (this.hitstun === 0 && !this.ctrl) this.runStateControllers();
if (this.hitstun === 0 && !this.grounded) this.vy += this.assets.stats.yaccel;
if (!this.grounded || this.hitstun > 0) { this.x += this.vx; this.y += this.vy; }
else {
this.x += this.vx;
const friction = this.crouch ? .82 : .85;
if (Math.abs(this.vx) > (this.crouch ? .05 : 2)) this.vx -= Math.sign(this.vx) * friction;
else this.vx = 0;
}
if (this.y >= 0) { this.y = 0; this.vy = 0; this.grounded = true; this.airJumps = 0; if (this.state >= 301 && this.state <= 303) this.setAction(47, 47); }
else this.grounded = false;
this.x = clamp(this.x, -145, 145);
this.elemTick++;
const fr = this.currentFrame();
if (fr && fr.time > 0 && this.elemTick >= fr.time) {
this.elemTick = 0; this.actionFrame++;
const action = this.assets.actions.get(this.action);
if (action && this.actionFrame >= action.frames.length) {
if (action.loopstart < action.frames.length) this.actionFrame = action.loopstart;
else if (this.state >= 5000 && this.state < 5100) this.setAction(0, 0);
else if (this.state >= 200 && this.state < 5000) {
const state = this.assets.states.get(this.state);
const target = state?.controllers.find(c => c.type === 'changestate' && c.values.get('trigger1')?.toLowerCase().includes('animtime'));
const next = target ? num(target.values.get('value'), 0) : (this.crouch ? 11 : 0);
this.setAction(next, next);
}
else this.actionFrame = Math.max(0, action.frames.length - 1);
}
}
for (const hd of this.hitDefs) {
const elemMatch = hd.activeElem === null || this.actionFrame + 1 === hd.activeElem;
const timeMatch = hd.activeTime === null || this.stateFrame === hd.activeTime;
let distanceMatch = true;
if (hd.distanceCondition) {
const d = Math.abs(foe.x - this.x), { op, value } = hd.distanceCondition;
distanceMatch = op === '<' ? d < value : op === '<=' ? d <= value : op === '>' ? d > value : d >= value;
}
if (!hd.hitUsed) hd.active = elemMatch && timeMatch && distanceMatch;
}
this.hit = this.hitDefs.find(hd => hd.active && !hd.hitUsed) || null;
this.frame = this.currentFrame();
this.facing = this.x <= foe.x ? 1 : -1;
}
handleInput(keys, pressed, foe) {
const down = keys.has('down'), forward = keys.has(this.facing > 0 ? 'right' : 'left'), back = keys.has(this.facing > 0 ? 'left' : 'right');
if (this.grounded && down) {
this.crouch = true;
if (![400, 410, 430, 440].includes(this.state)) this.setAction(11, 11);
} else this.crouch = false;
if (this.grounded && keys.has('up') && !this.jumpHeld) {
const action = forward ? 42 : back ? 43 : 41;
this.setAction(action, forward ? 302 : back ? 303 : 301);
this.vx = forward ? this.assets.stats.jumpX : back ? -this.assets.stats.jumpX : 0;
this.vy = this.assets.stats.jumpY; this.grounded = false; this.jumpHeld = true; return;
}
if (!keys.has('up')) this.jumpHeld = false;
if (this.grounded && forward && !down && !this.isAttack()) { if (this.state !== 20) this.setAction(20, 20); this.vx = this.assets.stats.walkFwd * this.facing; }
else if (this.grounded && back && !down && !this.isAttack()) { if (this.state !== 21) this.setAction(21, 21); this.vx = -this.assets.stats.walkBack * this.facing; }
else if (!down && !forward && !back && this.grounded && [11, 20, 21, 47].includes(this.state)) this.setAction(0, 0);
if (!this.canAttack()) return;
const button = ['x', 'y', 'a', 'b', 'z', 'c'].find(b => pressed.has(b));
if (!button) return;
const cmd = this.matchCommand(button, keys);
const motionState = this.motionState(button);
const state = motionState ?? this.resolveCommandState(button, keys, foe, cmd);
if (state !== null) this.setAction(state, state);
}
runStateControllers() {
const state = this.assets.states.get(this.state);
if (!state) return;
for (const c of state.controllers) {
if (c.type !== 'changestate') continue;
const trigger = c.values.get('trigger1')?.toLowerCase() || '';
const element = trigger.match(/animelem\s*=\s*(\d+)/);
if (!element || this.actionFrame + 1 !== +element[1]) continue;
const next = num(c.values.get('value'), this.state);
const ctrl = /ctrl\s*=\s*1/i.test(c.values.get('ctrl') || '');
this.setAction(next, next); this.ctrl = ctrl; return;
}
}
isAttack() { return (this.state >= 200 && this.state < 5000) || this.state >= 5000; }
canAttack() { return this.grounded ? (this.ctrl || (this.state >= 200 && this.state < 5000 && this.stateFrame > 5)) : this.state >= 301 && this.state <= 303 && this.stateFrame > 4; }
matchCommand(button, keys) {
const alternatives = this.assets.commands.filter(d => d.command.toLowerCase().endsWith(button));
return alternatives.find(d => this.motionMatches(d.command)) || alternatives.find(d => !/[FDBU]/.test(d.command)) ||
(this.motionMatches('~D, DF, F, ' + button) ? { name: button === 'x' ? 'QCF_x' : button === 'y' ? 'QCF_y' : `QCF_${button}` } : null) ||
(this.motionMatches('~F, D, DF, ' + button) ? { name: `upper_${button}` } : null) ||
(this.motionMatches('~D, DB, B, ' + button) ? { name: `QCB_${button}` } : null);
}
direction(keys) {
const x = (keys.has('right') ? 1 : 0) - (keys.has('left') ? 1 : 0), y = (keys.has('up') ? 1 : 0) - (keys.has('down') ? 1 : 0);
const fx = x * this.facing;
if (y > 0) return fx > 0 ? 'UF' : fx < 0 ? 'UB' : 'U';
if (y < 0) return fx > 0 ? 'DF' : fx < 0 ? 'DB' : 'D';
return fx > 0 ? 'F' : fx < 0 ? 'B' : 'N';
}
motionMatches(command) {
const tokens = command.replace(/~\d*/g, '').split(',').map(t => t.trim()).filter(t => t && !/[xyzabc+]/.test(t));
if (!tokens.length) return true;
const history = this.dirHistory.slice(-20).map(x => x.dir);
let pos = history.length - 1;
for (let i = tokens.length - 1; i >= 0; i--) {
const want = tokens[i].replace(/[/$]/g, '').toUpperCase();
while (pos >= 0 && history[pos] !== want) pos--;
if (pos < 0) return false;
pos--;
}
return true;
}
resolveCommandState(button, keys, foe, cmd) {
const crouch = keys.has('down'), air = !this.grounded;
const preferred = this.assets.cmdEntries.find(e => e.command === button && (!e.blockedByDown || !crouch) && (!e.requiresDown || crouch) &&
(!e.requiresState || e.requiresState === (air ? 'A' : crouch ? 'C' : 'S')) && (e.state < 3000 || this.power >= 1000) &&
(!e.requiresCtrl || this.ctrl) && (e.state !== 800 || Math.abs(foe.x - this.x) <= 38));
if (preferred) return preferred.state;
if (air) return button === 'x' || button === 'a' ? 600 : button === 'y' ? 610 : button === 'b' ? 640 : null;
if (cmd?.name?.startsWith('TripleKFPalm')) return 3000;
if (cmd?.name?.startsWith('SmashKFUpper')) return 3050;
if (cmd?.name?.startsWith('QCF_')) return button === 'x' ? 1000 : 1010;
if (cmd?.name?.startsWith('upper_')) return button === 'x' ? 1100 : 1110;
if (cmd?.name?.startsWith('QCB_')) return button === 'x' ? 1200 : 1210;
if (cmd?.name?.startsWith('FF_')) return button === 'a' ? 1050 : 1060;
if (button === 'z') return 195;
if (button === 'x') return crouch ? 400 : 200;
if (button === 'y') return crouch ? 410 : 210;
if (button === 'a') return crouch ? 430 : 230;
if (button === 'b') return crouch ? 440 : 240;
return null;
}
motionState(button) {
const rows = this.dirHistory.map(x => x.dir).filter(x => x !== 'N');
const last = rows.slice(-12);
const has = (dirs) => {
let pos = last.length - 1;
for (let i = dirs.length - 1; i >= 0; i--) {
while (pos >= 0 && last[pos] !== dirs[i]) pos--;
if (pos < 0) return false;
pos--;
}
return true;
};
if (has(['D', 'DF', 'F', 'D', 'DF', 'F']) && (button === 'x' || button === 'y') && this.power >= 1000) return 3000;
if (has(['D', 'DB', 'B', 'D', 'DB', 'B']) && (button === 'x' || button === 'y') && this.power >= 1000) return 3050;
if (has(['D', 'DF', 'F']) && button === 'x') return 1000;
if (has(['D', 'DF', 'F']) && button === 'y') return 1010;
if (has(['F', 'D', 'DF']) && button === 'x') return 1100;
if (has(['F', 'D', 'DF']) && button === 'y') return 1110;
if (has(['D', 'DB', 'B']) && button === 'x') return 1200;
if (has(['D', 'DB', 'B']) && button === 'y') return 1210;
if (has(['F', 'F']) && button === 'a') return 1050;
if (has(['F', 'F']) && button === 'b') return 1060;
if (has(['F']) && button === 'y') return 800;
return null;
}
aiKeys(foe) {
const k = new Set(), dist = Math.abs(foe.x - this.x), towards = foe.x > this.x ? 'right' : 'left';
if (dist > 52) k.add(towards);
else if (this.aiWait-- <= 0) { k.add(Math.random() < .55 ? 'x' : 'a'); this.aiWait = 24 + Math.random() * 45; }
return k;
}
updateAI(foe) { if (!this.player) { /* Input is produced inside update(). */ } }
get power() { return this._power || 0; }
set power(v) { this._power = v; }
}