Download js/runtime.js from HelloSun/sfdemo: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/spaces/HelloSun/sfdemo/resolve/main/js/runtime.js
- Command line
-
hf download hf://spaces/HelloSun/sfdemo/js/runtime.js
-
curl -L -o runtime.js https://huggingface.co/spaces/HelloSun/sfdemo/resolve/main/js/runtime.js
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; } | |
| } | |