/* AETHER CLASH — standalone deterministic canvas combat runtime. */ (() => { 'use strict'; const W = 1280, H = 720; const CONFIG = { bodyGap: 80, reachScale: 1.28, groundY: 584, walkSpeed: 320, runSpeed: 520, jumpSpeed: 830, doubleJumpSpeed: 790, gravity: 2900, fastFall: 1180, doubleTapWindow: .26, rollDuration: .28, ...window.STAGE_CONFIG }; const canvas = document.querySelector('#arena'), ctx = canvas.getContext('2d', { alpha: false }); const $ = s => document.querySelector(s); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const approach = (v, target, amount) => v < target ? Math.min(target, v + amount) : Math.max(target, v - amount); const BALANCE = { combo: [{damage:6,knockback:75},{damage:8,knockback:100},{damage:12,knockback:180}], skill1: {damage:16,cooldown:3,knockback:140}, skill2: {damage:24,cooldown:6,knockback:220,radius:220}, ultimate: {damagePerDrone:12,cooldown:18,castTime:.50,knockback:190}, dummyMax:200, heroMax:200, hpPerBar:100, basicCooldownRefund:.05 }; const cooldownMax = { skill1:BALANCE.skill1.cooldown, skill2:BALANCE.skill2.cooldown, ultimate:BALANCE.ultimate.cooldown }; const skillNames = { skill1: 'AETHER BOLT', skill2: 'SEISMIC DRIVE', ultimate: 'HELIOS SQUADRON' }; const images = {}, F = window.Fenr, M = window.Mira && window.MIRA_MANIFEST ? window.Mira : null, C = window.Cora && window.CORA_MANIFEST ? window.Cora : null, N = window.Naja && window.NAJA_MANIFEST ? window.Naja : null, HD = window.Haldor && window.HALDOR_MANIFEST ? window.Haldor : null, Z = window.Zanni && window.ZANNI_MANIFEST ? window.Zanni : null, IS = window.Isolde && window.ISOLDE_MANIFEST ? window.Isolde : null, RH = window.Rhea && window.RHEA_MANIFEST ? window.Rhea : null, SO = window.Solan && window.SOLAN_MANIFEST ? window.Solan : null, NB = window.Nib && window.NIB_MANIFEST ? window.Nib : null, ED = window.Edda && window.EDDA_MANIFEST ? window.Edda : null, BW = window.Browo && window.BROWO_MANIFEST ? window.Browo : null; const CF = window.FIGHTERS || {}; // data-driven fighters, see work/WORKFLOW.md // Kits that share one start/move/refund contract (mira.js, cora.js, naja.js, haldor.js, zanni.js, isolde.js, rhea.js, solan.js, nib.js, edda.js). const KITS = { ...(M ? { mira: M } : {}), ...(C ? { cora: C } : {}), ...(N ? { naja: N } : {}), ...(HD ? { haldor: HD } : {}), ...(Z ? { zanni: Z } : {}), ...(IS ? { isolde: IS } : {}), ...(RH ? { rhea: RH } : {}), ...(SO ? { solan: SO } : {}), ...(NB ? { nib: NB } : {}), ...(ED ? { edda: ED } : {}), ...(BW ? { browo: BW } : {}), ...Object.fromEntries(Object.entries(CF).map(([id, d]) => [id, cfKit(d)])) }; let manifest = window.MECHA_MANIFEST || window.SPRITE_MANIFEST || null, metrics = window.MECHA_METRICS || null; let selectedCharacter = 'arco', aiEnabled = !!F, fenrCutin = null, hudIdentity = ''; // Cadangan bila feel.js gagal dimuat (mis. start dingin offline): tanpa rasa kena (hit-stop, getaran, K.O. slow-motion, angka, peringkat), tetapi game tetap hidup. function feelFallback() { const no = () => {}, h = { stop: 0, trauma: 0, kick: 0, flash: 0, size: 1, ripple: 0 }; return { table: { medium: 10, colors: {}, hit: { medium: h, ultimate: h } }, classify: () => 'light', hit: () => h, rankOf: () => '', kickAt: no, kickOffset: () => ({ x: 0, y: 0 }), ripple: no, drawRipples: no, drawNumbers: no, tick: no, reset: no, comboHud: no, ageNumbers: (list, dt) => { for (let i = list.length - 1; i >= 0; i--) if ((list[i].life -= dt) <= 0) list.splice(i, 1); }, ko: { start: no, scale: () => 1, zoom: () => 1, drawOverlay: no, state: { on: false, cfg: { trauma: 0 } } } }; } const Rules = window.MatchRules, Feel = window.Feel || feelFallback(); // ---- Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2 / 3.3; data content/passives.js, helper passives.js) ---- // Titik kait tunggal: passiveMod(actor, key, base) membaca angka sisi aktor (hero = pemain; selain itu = lawan, CPU atau P2), jadi semua efek berlaku sama persis untuk kedua sisi. // Kunci pengali dikalikan ke base; kunci penambah (regen) dijumlahkan. Tanpa window.Passives semua angka netral dan game tetap berjalan. // Damage di game ini bilangan bulat, jadi pengali kecil (6%) dibulatkan dengan sisa yang dibawa ke pukulan berikutnya (pcarry): rata-rata tepat, tanpa pengacak. const sideMods = { player: {}, enemy: {} }, sideStyle = { player: 'seimbang', enemy: 'seimbang' }, pcarry = { player: 0, enemy: 0 }, pregen = { player: 0, enemy: 0 }; let hitKind = null, styleSeed = 7331; // hitKind = 'basic' selama kombo dasar sedang mengenai (untuk dmgBasic) function passiveMod(actor, key, base = 1) { const v = (actor === hero ? sideMods.player : sideMods.enemy)[key]; return v === undefined ? base : window.Passives?.KEYS?.[key]?.add ? base + v : base * v; } function styleRand() { styleSeed = (Math.imul(styleSeed, 1664525) + 1013904223) >>> 0; return styleSeed / 4294967296; } // pengacak terpisah: tidak menggeser random() mesin // styles = { player, enemy }: player kosong = pilihan di menu Karakter (Meta.styleOf), enemy kosong = Seimbang, 'acak' = gaya acak per pertandingan (CPU), // 'p2' = pilihan menu Karakter untuk karakter lawan (pemain kedua manusia; gaya melekat pada karakter, jadi simetris dengan sisi pemain). function refreshPassives(styles = {}) { const P = window.Passives; if (!P) { sideMods.player = {}; sideMods.enemy = {}; return; } sideStyle.player = P.styleKey(styles.player ?? window.Meta?.styleOf?.(selectedCharacter)); sideStyle.enemy = P.styleKey(styles.enemy === 'acak' ? P.randomStyle(styleRand) : styles.enemy === 'p2' ? window.Meta?.styleOf?.(opponentCharacter) : styles.enemy); sideMods.player = P.merge(selectedCharacter, sideStyle.player); sideMods.enemy = P.merge(opponentCharacter, sideStyle.enemy); } // Pertandingan sungguhan melawan CPU (punya layar VS, bukan P2) memakai gaya acak untuk CPU; dua pemain memakai gaya pilihan tiap karakter; latihan dan uji tanpa options.styles memakai Seimbang. function matchStyles(options, mode) { const o = options.styles || {}; return { player: o.player, enemy: o.enemy ?? (mode === 'versus' && options.vs ? (options.vs.p2 ? 'p2' : 'acak') : 'seimbang') }; } function passiveDamage(attacker, target, amount, basic) { if (!(amount > 0)) return amount; const mul = passiveMod(attacker, 'dmgOut') * passiveMod(attacker, basic ? 'dmgBasic' : 'dmgSkill') * passiveMod(target, 'dmgIn'); if (mul === 1) return amount; const side = attacker === hero ? 'player' : 'enemy', v = amount * mul + pcarry[side], r = Math.round(v); pcarry[side] = v - r; return r; } const cdRate = (actor, name) => passiveMod(actor, 'cd') * passiveMod(actor, name === 'ultimate' ? 'cdUlt' : 'cdSkill'); function strikeAs(kind, fn) { hitKind = kind; try { fn(); } finally { hitKind = null; } } // Statistik pertandingan (stats.js): sumber pukulan pemain untuk MatchStats.hit; null = jurus yang sudah lepas (proyektil, ultimate), dikaitkan stats.js ke jurus terakhir. const hitSource = () => ignoreBlock ? 'throw' : hitKind === 'basic' && hero.action?.type === 'attack' ? 'basic' : hero.action?.special ? 'special' : null; // regen: HP kecil per detik, hanya bila aktor hidup, tidak penuh, dan tidak terkena apa pun selama 3 detik (lastHurt diisi tiap damage masuk). function passiveRegen(dt) { if (match && match.phase !== 'fight') return; for (const [side, actor] of [['player', hero], ['enemy', dummy]]) { const r = passiveMod(actor, 'regen', 0) + (actor === hero ? window.Access?.regen || 0 : 0); if (!(r > 0) || !bwAlive(actor) || time - (actor.lastHurt ?? -99) < 3) continue; // + bantuan "Pemulihan darah pelan" (access.js), hanya sisi pemain if (actor === hero ? hero.hp >= BALANCE.heroMax : dummy.damage <= 0) { pregen[side] = 0; continue; } pregen[side] += r * dt; if (pregen[side] < 1) continue; const n = Math.floor(pregen[side]); pregen[side] -= n; if (actor === hero) hero.hp = Math.min(BALANCE.heroMax, hero.hp + n); else dummy.damage = Math.max(0, dummy.damage - n); } } // Baris untuk Daftar gerakan: [tanda, nama, angka, cerita] perk dan kelemahan sebuah sisi, ditambah baris GAYA hanya bila bukan Seimbang (hemat tinggi daftar). // Cerita hanya tampil di jendela cukup tinggi (CSS), angka selalu. function passiveRows(id, side) { const P = window.Passives, d = P?.describe(id, true); if (!d) return null; const rows = [['\u25B2', d.perk.name, d.perk.fx, d.perk.desc], ['\u25BC', d.flaw.name, d.flaw.fx, d.flaw.desc]]; if (sideStyle[side] !== P.DEFAULT_STYLE) { const st = P.styleView(sideStyle[side], true); rows.push(['GAYA', st.name, st.fx, st.desc]); } return rows; } let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED; let match = Rules?.create('training') || null, roundAnnouncer = null; // Layar VS (vs.js): selama vsHold urutan ronde ditahan sebelum cue 'Round 1' (tidak ada yang berjalan di match.js); matchBest = kombo terbaik di seluruh ronde. let vsHold = false, vsToken = 0, matchBest = 0; const roundCues = []; let ready = false, paused = false, settingsOpen = false, hitboxes = false, muted = false, volume = .45; let scale = 1, offsetX = 0, offsetY = 0, viewW = W, viewH = H, pixelRatio = 1; let time = 0, realTime = 0, lastTime = 0, accumulator = 0, hitstop = 0, trauma = 0, cinematic = 0, announceTimer = 0; let p2Mode = false; const keys2 = new Set(); let ignoreBlock = false; let seed = 18741, stepCount = 0, totalDamage = 0, comboHits = 0, bestCombo = 0, comboDamage = 0, comboTimer = 0, fps = 60; const keys = new Set(), taps = { a: -10, d: -10, s: -10 }; const particles = [], effects = [], projectiles = [], damageNumbers = []; let squad = null, enemySquad = null, parade = null, enemyParade = null, flock = null, enemyFlock = null, serpent = null, enemySerpent = null, quake = null, enemyQuake = null, finale = null, enemyFinale = null, skyfall = null, enemySkyfall = null, orrery = null, enemyOrrery = null, sunroar = null, enemySunroar = null, delivery = null, enemyDelivery = null, tortoise = null, enemyTortoise = null; const rechargePulse = {skill1:0,skill2:0,ultimate:0}; // CPU memory: what it has already read about the player, and its current plan. let cpu = { seen: new WeakMap(), decided: new WeakSet(), read: null, followPunish: null, heroActionAt: 0, heroAction: null, airborne: false, airAt: 0, antiAirRead: false, plan: null, doubleAt: 0 }; const SQUAD = { duration: 2.85, cutinDuration: .78, firstShot: 1.50, shotGap: .095, departure: 2.24, droneWidth: 112 }; const hero = { x: 350, y: CONFIG.groundY, vx: 0, vy: 0, facing: 1, grounded: true, jumps: 0, state: 'idle', animTime: 0, walkPhase: 0, smokeDistance:0, action: null, runDirection: 0, hp: BALANCE.heroMax, koTime:0, invuln: 0, hurtTime: 0, doublePose: 0, rollDuration: CONFIG.rollDuration, rollBuffer: null, airTime: 0, cooldowns: { skill1: 0, skill2: 0, ultimate: 0 } }; const dummy = { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0, respawnX: 915 }; function random() { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; } function loadImage(name, src, required = false) { return new Promise(resolve => { const img = new Image(); img.onload = () => { images[name] = img; resolve(true); }; img.onerror = () => resolve(!required); img.src = src; }); } function resize() { viewW = Math.max(1, innerWidth); viewH = Math.max(1, innerHeight); pixelRatio = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(viewW * pixelRatio); canvas.height = Math.round(viewH * pixelRatio); scale = Math.min(viewW / W, viewH / H); offsetX = (viewW - W * scale) / 2; // Keep the same world floor and complete combat width on every screen. offsetY = viewH * .815 - CONFIG.groundY * scale; ctx.imageSmoothingEnabled = false; } function announce(text, seconds = 1.3) { $('#announcement').textContent = text; announceTimer = seconds; } function state(name) { if (hero.state !== name) { hero.state = name; hero.animTime = 0; } } function frames(name) { return manifest?.frame_layout?.rows?.[name] || []; } function animationDuration(name, fallback) { const row = manifest?.animation?.rows?.[name]; const count = frames(name).length; return row?.fps && count ? count / row.fps : fallback; } function spawnParticles(x, y, color, n, power = 220, kind = 'spark') { for (let i = 0; i < n; i++) { const a = random() * Math.PI * 2, speed = (random() * .8 + .3) * power; particles.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed - 65, life: .2 + random() * .4, maxLife: .6, size: 2 + random() * 4, color, kind }); } } function dust(x, n = 9) { spawnParticles(x, CONFIG.groundY - 5, '#d6c6a3', n, 80, 'dust'); } function runSmoke(x,facing) { for(let i=0;i<2;i++) particles.push({kind:'run-smoke',x:x-facing*(18+i*8),y:CONFIG.groundY-2-i*3-random()*4,vx:-facing*(35+random()*40),vy:-13-random()*12,life:.32+random()*.14,maxLife:.46,size:8+random()*5,color:'#d9e1d5'}); } // A user gesture unlocks audio; AI voices stay silent until then. let audio = null, master = null, compressor = null, audioSources = 0; let systemAnnouncer = null; // Background music: one looping HTMLAudio outside the effects/voice mix. Default 5%; Settings turns it on/off and sets // its own volume (remembered on this device). Like every sound it starts only after the first user gesture, pauses in a // hidden tab, follows the master mute, and dips while the announcer or an ultimate voice is speaking. const MUSIC_PATH = 'assets/audio/music/midday-showdown.mp3'; const prefs = { get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (_) { return d; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (_) {} } }; let musicOn = prefs.get('aether.music', '1') !== '0', musicVolume = clamp(Number(prefs.get('aether.musicVolume', document.documentElement.classList.contains('touch') ? '5' : '15')) / 100 || 0, 0, 1), music = null; try { if (typeof Audio !== 'undefined') { music = new Audio(); music.loop = true; music.preload = 'none'; music.src = MUSIC_PATH; } } catch (_) { music = null; } function syncMusic() { if (!music) return; const speaking = systemAnnouncer?.speaking || (voiceActive && ultimateVoice && !ultimateVoice.paused); music.muted = muted; music.volume = clamp(musicVolume * (speaking ? .55 : 1), 0, 1); const want = musicOn && musicVolume > 0 && !!audio && !document.hidden; if (want && music.paused) music.play()?.catch?.(() => {}); else if (!want && !music.paused) music.pause(); } const ULTIMATE_VOICE_PATH = 'assets/audio/arco-ultimate-dylan.mp3'; const FENR_VOICE_PATH = 'assets/fenr/audio/fenr-ultimate-holden.mp3'; const MIRA_VOICE_PATH = 'assets/mira/audio/mira-ultimate-luna.mp3', CORA_VOICE_PATH = 'assets/cora/audio/cora-ultimate-anika.mp3', NAJA_VOICE_PATH = 'assets/naja/audio/naja-ultimate-soraya.mp3', HALDOR_VOICE_PATH = 'assets/haldor/audio/haldor-ultimate.mp3', ZANNI_VOICE_PATH = 'assets/zanni/audio/zanni-ultimate.mp3', ISOLDE_VOICE_PATH = 'assets/isolde/audio/isolde-ultimate.mp3', RHEA_VOICE_PATH = 'assets/rhea/audio/rhea-ultimate.mp3', SOLAN_VOICE_PATH = 'assets/solan/audio/solan-ultimate.mp3', NIB_VOICE_PATH = 'assets/nib/audio/nib-ultimate.mp3', EDDA_VOICE_PATH = 'assets/edda/audio/edda-ultimate.mp3'; const VOICE_NAMES = { arco: 'Dylan', fenr: 'Holden', mira: 'Luna', cora: 'Anika', naja: 'Soraya', haldor: 'Gideon', zanni: 'Julian', isolde: 'Vesper', rhea: 'Chloe', solan: 'Xavier', nib: 'Evan', edda: 'Opal', browo: 'Bro Wo' }; const BROWO_VOICE_PATH = 'assets/browo/audio/browo-ultimate.mp3'; const voiceBank = {}; let voiceKind = 'arco', voiceName = 'Dylan', voiceActor = null, voiceClock = 0; let ultimateVoice = null, voiceActive = false, voiceSerial = 0, voicePlayToken = 0, voiceStarts = 0, voiceError = ''; try { if (typeof Audio !== 'undefined') { for (const [kind, path] of [['arco', ULTIMATE_VOICE_PATH], ...(F ? [['fenr', FENR_VOICE_PATH]] : []), ...(M ? [['mira', MIRA_VOICE_PATH]] : []), ...(C ? [['cora', CORA_VOICE_PATH]] : []), ...(N ? [['naja', NAJA_VOICE_PATH]] : []), ...(HD ? [['haldor', HALDOR_VOICE_PATH]] : []), ...(Z ? [['zanni', ZANNI_VOICE_PATH]] : []), ...(IS ? [['isolde', ISOLDE_VOICE_PATH]] : []), ...(RH ? [['rhea', RHEA_VOICE_PATH]] : []), ...(SO ? [['solan', SOLAN_VOICE_PATH]] : []), ...(NB ? [['nib', NIB_VOICE_PATH]] : []), ...(ED ? [['edda', EDDA_VOICE_PATH]] : []), ...(BW ? [['browo', BROWO_VOICE_PATH]] : []), ...Object.keys(CF).map(id => [id, 'assets/' + id + '/audio/' + id + '-ultimate.mp3'])]) { const media = new Audio(); media.preload = 'auto'; voiceBank[kind] = media; media.addEventListener('ended', () => { if (ultimateVoice !== media) return; voiceActive = false; applyAudioMix(); }); media.addEventListener('error', () => { if (ultimateVoice !== media) return; voiceError = 'audio-load-failed'; voiceActive = false; applyAudioMix(); }); media.addEventListener('playing', () => { if (ultimateVoice !== media) { media.pause(); return; } if (!voiceActive || !voiceRelevant()) { stopUltimateVoice(); return; } const elapsed = voiceElapsed(); if (media.currentTime < .05 && elapsed > .2 && Number.isFinite(media.duration)) media.currentTime = Math.min(elapsed, media.duration); applyAudioMix(); }); media.src = path; media.load(); } ultimateVoice = voiceBank.arco; } } catch (_) { voiceError = 'audio-unavailable'; } if(window.SystemAnnouncer && window.ANNOUNCER_MANIFEST)systemAnnouncer=window.SystemAnnouncer.create({ clips:window.ANNOUNCER_MANIFEST.clips,canPlay:()=>!!audio&&!document.hidden,isPaused:()=>paused||settingsOpen||document.hidden, onStart:()=>stopUltimateVoice(),onActivity:()=>applyAudioMix() }); function voiceElapsed() { return voiceKind === 'fenr' ? (voiceActor?.transformElapsed ?? Infinity) : voiceKind === 'arco' ? ((voiceActor === dummy ? enemySquad : squad)?.t ?? Infinity) : voiceClock; } function voiceRelevant() { if (voiceKind === 'arco') return !!(voiceActor === dummy ? enemySquad : squad); const alive = !!voiceActor && (voiceActor === hero ? hero.hp > 0 : !dummy.ko); // MIRA/CORA/NAJA/HALDOR/ZANNI/ISOLDE/RHEA/SOLAN/NIB/EDDA lines run longer than their salvo; they finish unless the caster is K.O. or the fight resets. if (voiceKind !== 'fenr') return alive; return alive && (voiceActor.transformPending > 0 || voiceActor.form === 'wolf'); } function applyAudioMix() { if (master) master.gain.value = muted ? 0 : volume * ((systemAnnouncer?.speaking || (voiceActive && ultimateVoice && !ultimateVoice.paused)) ? .38 : 1); for (const media of Object.values(voiceBank)) { media.muted = muted; media.volume = clamp(volume * 1.25, 0, 1); } systemAnnouncer?.setMix(volume,muted); syncMusic(); } function stopUltimateVoice() { voiceSerial++; voicePlayToken++; voiceActive = false; if (ultimateVoice) { ultimateVoice.pause(); try { ultimateVoice.currentTime = 0; } catch (_) {} } applyAudioMix(); } function syncUltimateVoice() { systemAnnouncer?.sync(); if (!ultimateVoice) return; if (!voiceActive || paused || settingsOpen || document.hidden) { voicePlayToken++; ultimateVoice.pause(); applyAudioMix(); return; } if (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration)) { stopUltimateVoice(); return; } if (ultimateVoice.paused) { const serial = voiceSerial, playToken = ++voicePlayToken; const play = ultimateVoice.play(); if (play?.catch) play.catch(error => { if (serial !== voiceSerial || playToken !== voicePlayToken) return; voiceActive = false; voiceError = error?.name || 'audio-playback-failed'; applyAudioMix(); }); } applyAudioMix(); } function startUltimateVoice(kind = 'arco', actor = hero) { // One speaking channel. Player calls take priority over an AI transformation. if (!audio || systemAnnouncer?.busy || !voiceBank[kind] || (actor === dummy && voiceActive)) return; stopUltimateVoice(); ultimateVoice = voiceBank[kind]; voiceKind = kind; voiceActor = actor; voiceName = VOICE_NAMES[kind]; voiceClock = 0; try { ultimateVoice.currentTime = 0; } catch (_) {} voiceActive = true; voiceStarts++; voiceError = ''; syncUltimateVoice(); } function unlockAudio() { try { if (!audio) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; audio = new AC(); master = audio.createGain(); compressor = audio.createDynamicsCompressor(); master.connect(compressor); compressor.connect(audio.destination); } if (audio.state === 'suspended') audio.resume(); applyAudioMix(); } catch (_) { /* Audio is optional; gameplay remains usable. */ } } function sound(type) { if (!audio || audio.state !== 'running' || muted) return; const start = audio.currentTime, heavy = type === 'heavy' || type === 'ultimate'; const duration = type === 'ultimate' ? .7 : heavy ? .26 : type === 'cast' ? .22 : .13; const osc = audio.createOscillator(), gain = audio.createGain(); osc.type = type === 'jump' || type === 'cast' ? 'sine' : 'triangle'; const frequency = ({ hit: 210, heavy: 110, cast: 430, jump: 360, ultimate: 72, step: 75, click: 550, guard: 640, parry: 1250 })[type] || 190; osc.frequency.setValueAtTime(frequency, start); osc.frequency.exponentialRampToValueAtTime(type === 'jump' || type === 'cast' ? frequency * 2.5 : frequency * .22, start + duration); gain.gain.setValueAtTime(.001, start); gain.gain.exponentialRampToValueAtTime(heavy ? .32 : .12, start + .008); gain.gain.exponentialRampToValueAtTime(.001, start + duration); osc.connect(gain); gain.connect(master); osc.start(start); osc.stop(start + duration + .02); audioSources++; if (type !== 'jump' && type !== 'click') { const buffer = audio.createBuffer(1, Math.floor(audio.sampleRate * duration), audio.sampleRate), data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2); const source = audio.createBufferSource(), filter = audio.createBiquadFilter(), noiseGain = audio.createGain(); source.buffer = buffer; filter.type = 'bandpass'; filter.frequency.value = heavy ? 430 : 1800; filter.Q.value = .7; noiseGain.gain.value = heavy ? .35 : .14; source.connect(filter); filter.connect(noiseGain); noiseGain.connect(master); source.start(start); audioSources++; } } function menuSound(kind='move') { unlockAudio();if(!audio || muted)return; // pick: the lock-in chime when a fighter is confirmed; blink: the soft tick of each following flash. const notes={move:[580,820],confirm:[440,660,880],back:[440,300],locked:[155,125],round:[330,440],fight:[440,880,1320],pick:[523,784,1047,1568],blink:[1175]}[kind]||[580]; const gap=kind==='pick'?.045:.035,peak=kind==='blink'?.03:kind==='pick'?.085:.075,tail=kind==='pick'?.24:.095; for(const [i,n] of notes.entries()){const at=audio.currentTime+i*gap,o=audio.createOscillator(),g=audio.createGain();o.type=kind==='locked'||kind==='pick'?'triangle':'sine';o.frequency.setValueAtTime(n,at);g.gain.setValueAtTime(.001,at);g.gain.exponentialRampToValueAtTime(peak,at+.005);g.gain.exponentialRampToValueAtTime(.001,at+tail);o.connect(g);g.connect(master);o.start(at);o.stop(at+tail+.015);audioSources++;} } function setMenuOpen(value) { menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;Feel.reset();setPaused(false);} if(menuOpen)vsAbort(); document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen; window.FrontEnd?.syncBackground(); } function playable() { return ['arco','fenr',...Object.keys(KITS)]; } function startMatch(options={}) { if(!ready||!Rules)return false; const {player='arco',enemy='fenr',stage='bellora',level='medium',mode='versus'}=options; p2Mode=false; keys2.clear(); if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false; if(!images['stage-'+stage])return false; selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;refreshPassives(matchStyles(options,mode)); setShadowFoe(!!options.shadow&&mode==='versus'); aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins(); window.MatchStats?.reset(mode==='versus'); $('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus'); if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle},shadow:shadowFoe})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3); return true; } // Layar VS (opsional: options.vs = true | {label,p2}; hanya mode versus, tidak ada di latihan). Mengembalikan true bila layar tampil; // cue 'Round 1' baru keluar saat layar selesai/dilewati, sisanya (intro, FIGHT, announcer) berjalan persis seperti tanpa layar VS. function vsStart(opt,info){ const tok=++vsToken;vsHold=true; const shown=window.VS?.show({...info,...(typeof opt==='object'?opt:{})},()=>{if(tok!==vsToken||!vsHold)return;vsHold=false;announceTimer=0;emitRoundCue('round',1);}); if(shown&&info.shadow){try{window.Story?.decorateVs?.(info);}catch(e){console.error(e);}} if(!shown)vsHold=false;return !!shown; } function vsAbort(){vsToken++;vsHold=false;window.VS?.cancel?.();} // ---- Bayangan: bos rahasia Arcade (RENCANA 3.6) ---- // startMatch({..., shadow: true}) membuat lawan salinan karakter pemain (id sama; state hero dan dummy tetap terpisah, jurus dan CPU memakai data yang sama): // HP +40%, damage ke pemain +10% (receiveHit dan dotDamage lewat foeDamage), sprite digelapkan dan diungukan (filter kanvas, atau lapisan warna bila tanpa ctx.filter; tanpa gambar baru) // dengan aura serta percikan ungu. Bar HP dan penentu waktu habis memakai skala HP dasar (dummyHpShown) supaya +40% tidak menggeser tampilan atau hasil. // Percikan memakai fxr() (pengacak kosmetik), jadi tidak memengaruhi hasil pertandingan. const SHADOW = { hp: 1.4, damage: 1.1, filter: 'grayscale(1) brightness(.46) sepia(1) hue-rotate(228deg) saturate(2.2)', tint: '#3b1d86', tintAlpha: .62, aura: '139,92,246', wisp: '#a78bfa' }; const FILTER_OK = typeof ctx.filter === 'string'; // peramban tanpa ctx.filter (Safari lama) memakai lapisan warna ungu saja const DUMMY_MAX = BALANCE.dummyMax; let shadowFoe = false; function setShadowFoe(on) { shadowFoe = !!on; BALANCE.dummyMax = shadowFoe ? Math.round(DUMMY_MAX * SHADOW.hp) : DUMMY_MAX; hudIdentity = ''; } const foeDamage = n => (shadowFoe && n > 0 ? Math.max(1, Math.round(n * SHADOW.damage)) : n); const dummyHpShown = () => currentDummyHP() * DUMMY_MAX / BALANCE.dummyMax; function drawShadowAura() { const x = dummy.x, y = dummy.y - 88, k = .5 + .5 * Math.sin(realTime * 3.4), g = ctx.createRadialGradient(x, y, 10, x, y, 150); g.addColorStop(0, `rgba(${SHADOW.aura},${(.2 + .08 * k).toFixed(3)})`); g.addColorStop(1, `rgba(${SHADOW.aura},0)`); ctx.save(); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 150, 0, 7); ctx.fill(); ctx.restore(); } // Lapisan warna ungu hanya di piksel sprite (seperti kilat putih drawFlash); dipakai hanya bila peramban tidak punya ctx.filter. function drawShadowTint(img, r, dx, dy) { flashCv.width = r.w; flashCv.height = r.h; flashCx.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h); flashCx.globalCompositeOperation = 'source-atop'; flashCx.fillStyle = SHADOW.tint; flashCx.fillRect(0, 0, r.w, r.h); flashCx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = SHADOW.tintAlpha; ctx.drawImage(flashCv, dx, dy, r.w, r.h); ctx.globalAlpha = 1; } function shadowFx(dt) { if (menuOpen || dummy.ko || fxr() >= dt * 14) return; fxPart(dummy.x + (fxr() - .5) * 54, dummy.y - 8 - fxr() * 120, (fxr() - .5) * 24, -46 - fxr() * 54, .9, 3 + fxr() * 4, SHADOW.wisp, 'ember', { g: -26, drag: 1 }); } function emitRoundCue(cue,round) { const event={cue,round,text:cue==='round'?`Round ${round}`:'Fight!'};roundCues.push(event);if(roundCues.length>12)roundCues.shift(); if(audio)menuSound(cue);if(roundAnnouncer)try{roundAnnouncer(event);}catch(_){} systemAnnouncer?.play(cue==='round'?`round_${round}`:'fight',{replace:cue==='round'}); } function advanceMatch(dt) { if(!Rules||!match||match.mode!=='versus'||vsHold)return; window.MatchStats?.tick(dt,match,hero.hp,dummy.damage,BALANCE.dummyMax); for(const event of Rules.tick(match,dt,hero.hp,dummyHpShown())) { if(event.type==='cue')emitRoundCue(event.cue,event.round); if(event.type==='round-end') { matchBest=Math.max(matchBest,bestCombo);clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0; if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;} if(!event.timeout&&event.winner!=='draw'||event.doubleKO)startKoFx(event.doubleKO?'both':event.winner==='player'?'dummy':'hero',!!match.winner); match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy'); const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko'; const outcome=event.winner==='draw'?'draw':`${event.winner==='player'?selectedCharacter:opponentCharacter}_wins`; const speech=window.ANNOUNCER_MANIFEST?.clips; if(speech)match.koDuration=Math.max(2.2,(speech[call]?.duration||0)+(speech[outcome]?.duration||0)+.5); systemAnnouncer?.play([call,outcome]); } if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);} if(event.type==='complete'){ // bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil. const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0,boss:shadowFoe}; info.stats=window.MatchStats?.finish(info)||null; const metaHandled=!!window.Meta?.award?.(info); if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null}); } } } function updateMatchHud() { if(!Rules)return; const versus=match?.mode==='versus',s=Rules.stages[stageId]; $('#mode-label').textContent=versus?`ROUND ${match.round} / 3`:'FREE TRAINING';$('#match-clock').textContent=versus?String(Math.ceil(match.seconds)).padStart(2,'0'):'∞'; $('.match-center').setAttribute('aria-label',versus?'Pertandingan, menang dua ronde dari maksimal tiga':'Mode latihan tanpa batas waktu'); $('#match-score').textContent=versus?`${match.playerWins} — ${match.enemyWins}`:'VS';$('#stage-name').textContent=s.short;$('#stage-subtitle').textContent=s.subtitle; $('#round-wins-player').textContent=versus?'◆'.repeat(match.playerWins)+'◇'.repeat(2-match.playerWins):'';$('#round-wins-enemy').textContent=versus?'◆'.repeat(match.enemyWins)+'◇'.repeat(2-match.enemyWins):''; const card=$('#round-call'),show=!menuOpen&&versus&&!vsHold&&['intro','ko'].includes(match.phase); card.hidden=!show;card.dataset.phase=match?.phase||'fight'; $('#round-call-title').textContent=!show?'':match.phase==='intro'?(match.fightCue?'FIGHT':`ROUND ${match.round}`):match.endText||'K.O.'; $('#round-call-detail').textContent=match?.phase==='ko'?(match.lastWinner==='draw'?'DRAW · ROUND REPLAY':(match.lastWinner==='player'?selectedCharacter:opponentCharacter).toUpperCase()+' WINS THE ROUND'):'FIRST TO TWO WINS'; canvas.dataset.mode=match?.mode||'training';canvas.dataset.matchPhase=match?.phase||'fight';canvas.dataset.round=String(match?.round||1);canvas.dataset.opponent=opponentCharacter;canvas.dataset.difficulty=difficulty;canvas.dataset.stage=stageId; } function canAct() { return ready && !menuOpen && (!match || match.phase === 'fight') && !paused && !settingsOpen && hero.hurtTime <= 0 && hero.hp > 0; } function jump(second = false) { if (!canAct()) return false; // A basic attack never locks the player: after its hit a jump cancels the recovery; pressed during the wind-up it is // kept and happens the moment the hit lands (the swing still connects). if (hero.action?.type === 'attack') { if (!hero.action.fired) { hero.action.cancelInto = 'jump'; hero.action.queued = 0; return true; } hero.action = null; } if (hero.action) return false; if (second) { if (hero.jumps >= 2) return false; hero.jumps = 2; hero.vy = -CONFIG.doubleJumpSpeed; hero.grounded = false; state('doublejump'); effects.push({ type: 'airpulse', x: hero.x, y: hero.y - 8, life: .5, maxLife: .5 }); spawnParticles(hero.x, hero.y - 7, '#b3fff2', 22, 190); sound('cast'); hero.rollDuration = CONFIG.rollDuration; hero.doublePose = hero.rollDuration; hero.airTime = 0; return true; } if (!hero.grounded && hero.jumps >= 2) return false; if (!hero.grounded) return jump(true); hero.jumps = 1; hero.vy = -CONFIG.jumpSpeed; hero.grounded = false; hero.airTime = 0; state('jump'); dust(hero.x, 12); sound('jump'); return true; } function startAttack(index = 1) { if (!canAct()) return false; if (hero.doublePose > 0) { hero.rollBuffer = 'attack'; return true; } if (hero.action) { if (hero.action.type === 'attack' && hero.action.index < 3) { hero.action.queued = Math.min(3 - hero.action.index, hero.action.queued + 1); hero.action.cancelInto = null; } return false; } const name = `attack${index}`, duration = selectedCharacter === 'fenr' ? F.move(hero,'attack',index).duration : KITS[selectedCharacter] ? KITS[selectedCharacter].move('attack',index).duration : animationDuration(name, [.39, .43, .57][index - 1]); hero.action = { type: 'attack', index, name, t: 0, duration, hit: false, queued: index === 1 && window.Access?.comboAuto ? 2 : 0, fired: false }; // kombo otomatis (access.js): pukulan 1 langsung mengantre dua pukulan berikutnya window.MatchStats?.cast('attack', hero.action); hero.runDirection = 0; state(name); return true; } // Skills cancel a basic attack at any point, so the kit never waits for the combo to finish. A skill that cannot start // (cooldown, summon still out) leaves the attack untouched. function cast(name) { const basic = hero.action?.type === 'attack' ? hero.action : null; if (basic && canAct() && (hero.cooldowns[name] || 0) <= 0) hero.action = null; const ok = castMove(name); if (ok) window.MatchStats?.cast(name, hero.action); if (!ok && basic && !hero.action) { hero.action = basic; state(basic.name); } return ok; } function castMove(name) { if(selectedCharacter === 'fenr') { if(!canAct() || hero.action || !Object.hasOwn(cooldownMax,name))return false; if(hero.doublePose>0){hero.rollBuffer=name;return true;} if(!F.start(hero,name))return false; hero.animTime=0;hero.runDirection=0; if(name==='ultimate')showFenrTransformation(hero); sound(name==='ultimate'?'ultimate':'cast');return true; } if(KITS[selectedCharacter]) { if(!canAct() || hero.action || !Object.hasOwn(cooldownMax,name) || (name==='ultimate'&&(parade||flock||serpent||quake||finale||skyfall||orrery||sunroar||delivery||tortoise)))return false; if(hero.doublePose>0){hero.rollBuffer=name;return true;} if(!KITS[selectedCharacter].start(hero,name))return false; hero.animTime=0;hero.runDirection=0; if(name==='ultimate')startSummon(hero); sound(name==='ultimate'?'ultimate':'cast');if(name==='ultimate')window.Meta?.shout?.(KITS[selectedCharacter].names?.[3]);return true; } if (!canAct() || hero.action || hero.cooldowns[name] > 0 || !Object.hasOwn(cooldownMax, name) || (name === 'ultimate' && squad)) return false; if (hero.doublePose > 0) { hero.rollBuffer = name; return true; } hero.cooldowns[name] = cooldownMax[name]; hero.runDirection = 0; const fallback = { skill1: .66, skill2: .85, ultimate: 1.9 }[name]; hero.action = { type: name, name, t: 0, duration: name === 'ultimate' ? BALANCE.ultimate.castTime : animationDuration(name, fallback), fired: false, hit: false }; state(name); sound(name === 'ultimate' ? 'ultimate' : 'cast'); if (name === 'ultimate') startSquadron(); return true; } function press(key, fromTest = false) { key = key.toLowerCase(); if (!fromTest) unlockAudio(); if(menuOpen) {if(key==='escape'&&settingsOpen)closeSettings();return;} if (key === 'escape') { if (settingsOpen) closeSettings(); else setPaused(!paused); return; } if (key === 'r') { reset(); return; } if (p2Mode && P2_KEYS.includes(key)) { p2Press(key); return; } if (!canAct() || keys.has(key)) return; keys.add(key); logInput(key); if (key === 'l') { hero.parry = .16 * passiveMod(hero, 'parry'); } if (key === 'j') startThrow(); if (key === 'a' || key === 'd') { const direction = key === 'a' ? -1 : 1; if (time - taps[key] <= CONFIG.doubleTapWindow) hero.runDirection = direction; else if (hero.runDirection !== direction) hero.runDirection = 0; taps[key] = time; taps[key === 'a' ? 'd' : 'a'] = -10; } // A new left/right press cuts a basic attack short: right away after its hit, or as soon as the hit lands. if ((key === 'a' || key === 'd') && hero.action?.type === 'attack') { if (hero.action.fired) { hero.action = null; state(hero.grounded ? 'idle' : 'jump'); } else { hero.action.cancelInto = 'move'; hero.action.queued = 0; } } if (key === 'w') jump(); if (key === 's') taps.s = time; if (key === ' ') { if (!startCommand()) startAttack(); } if (key === 'k') useShopMove(); if (key === 'i') cast('skill1'); if (key === 'o') cast('skill2'); if (key === 'p') cast('ultimate'); } // ---- Command moves, shop moves and game-feel helpers (see ROADMAP.md) ---- // Down-down+Space is an uppercut launcher, down-forward+Space a lunging punch. They reuse the fighter's own attack poses. const inputLog = []; const STATE_SCALE = { jump: 1.14, crouch: 1.1 }; // Every pose is drawn so its height matches the fighter's idle height as closely as the pose allows (walk, run, jump and // crouch were generated smaller than idle); computed once from the measured atlas bounds. const stateKCache = new WeakMap(); function stateK(mt, st) { if (!['walk', 'run', 'crouch', 'jump'].includes(st)) return 1; if (!mt?.states) return STATE_SCALE[st] || 1; let c = stateKCache.get(mt); if (!c) stateKCache.set(mt, c = {}); if (c[st] === undefined) { const h = n => { const f = mt.states[n]?.frames; return f?.length ? f.reduce((a, x) => a + (x.bounds.bottom - x.bounds.top), 0) / f.length : 0; }; const idle = h('idle'), cur = h(st); c[st] = idle && cur ? Math.min(st === 'run' ? 1.2 : 1.25, Math.max(1, idle / cur * ({ walk: .97, run: .97, crouch: .85, jump: .95 })[st])) : 1; } return c[st]; } function logInput(key) { if (!'sad'.includes(key)) return; inputLog.push({ k: key, t: time }); if (inputLog.length > 6) inputLog.shift(); } function commandFor() { const fwd = hero.facing > 0 ? 'd' : 'a', n = inputLog.length, last = inputLog[n - 1], prev = inputLog[n - 2]; if (!last || time - last.t > .55) return null; if (last.k === 's' && prev?.k === 's' && last.t - prev.t <= .4) return 'uppercut'; if (last.k === fwd && prev?.k === 's' && last.t - prev.t <= .4) return 'lunge'; return null; } const COMMANDS = { uppercut: { label: 'UPPERCUT!', state: 'attack3', cd: 2.6, duration: .5, hitAt: .36, damage: 15, knockback: 260 }, lunge: { label: 'TINJU MAJU!', state: 'attack2', cd: 1.8, duration: .44, hitAt: .3, damage: 11, knockback: 300 } }; function startCommand() { const id = commandFor(); if (!id || !canAct() || hero.action || hero.doublePose > 0 || (hero.spCd || 0) > 0 || !hero.grounded) return false; const c = COMMANDS[id]; inputLog.length = 0; hero.spCd = c.cd; hero.action = { type: 'attack', name: c.state, index: 3, t: 0, duration: c.duration, hit: false, queued: 0, fired: false, hitAt: c.hitAt, special: id, color: '#ffe9a0' }; hero.runDirection = 0; state(c.state); announce(c.label, .6); sound('cast'); window.Meta?.shout?.(c.label.replace('!', '')); return true; } const SHOP_MOVES = { seruduk: { name: 'SERUDUK KILAT', cd: 7, state: 'attack2', duration: .45, hitAt: .3, damage: 14, knockback: 340 }, hentak: { name: 'HENTAKAN BUMI', cd: 10, state: 'attack3', duration: .55, hitAt: .42, damage: 16, knockback: 200 }, perisai: { name: 'PERISAI', cd: 16, instant: true }, pulih: { name: 'AURA PEMULIH', cd: 30, instant: true }, isiulang: { name: 'ISI ULANG', cd: 35, instant: true } }; function useShopMove() { const id = window.Meta?.equippedMove?.(), m = SHOP_MOVES[id]; if (!m || !canAct() || hero.action || hero.doublePose > 0 || (hero.shopCd || 0) > 0) return false; hero.shopCd = window.Access?.noCooldown ? 0 : m.cd; announce(m.name, .7); sound('cast'); if (m.instant) { if (id === 'perisai') { hero.invuln = Math.max(hero.invuln, 1.6); hero.shield = 1.6; } if (id === 'pulih') { hero.hp = Math.min(BALANCE.heroMax, hero.hp + 25); damageNumbers.push({ x: hero.x, y: hero.y - 150, amount: '+25', life: .9, maxLife: .9, color: '#8dffa8' }); } if (id === 'isiulang') { hero.cooldowns.skill1 = hero.cooldowns.skill2 = 0; rechargePulse.skill1 = rechargePulse.skill2 = .3; } spawnParticles(hero.x, hero.y - 90, id === 'pulih' ? '#8dffa8' : '#9fe8ff', 26, 240); return true; } hero.action = { type: 'attack', name: m.state, index: 3, t: 0, duration: m.duration, hit: false, queued: 0, fired: false, hitAt: m.hitAt, special: id, color: '#9fe8ff' }; hero.runDirection = 0; state(m.state); return true; } function specialHit(a) { const dx = (dummy.x - hero.x) * hero.facing, y = hero.y - 100, near = Math.abs(dummy.y - hero.y) < 150, col = a.color || '#ffe9a0'; effects.push({ type: 'slash', x: hero.x + hero.facing * 60, y, life: .28, maxLife: .28, facing: hero.facing, index: 3, color: col }); if (a.special === 'hentak') { effects.push({ type: 'slam', x: hero.x, y: CONFIG.groundY - 2, life: .8, maxLife: .8 }); spawnParticles(hero.x, CONFIG.groundY - 8, col, 36, 360); trauma = Math.min(1, trauma + .4); sound('heavy'); if (Math.abs(dx) < 230 && dummy.y > CONFIG.groundY - 125) hitDummy(SHOP_MOVES.hentak.damage, 200, col, hero.x, y, true); return; } if (a.special === 'throw') { if (Math.abs(dx) < 125 && dummy.y >= CONFIG.groundY - 1 && !['down', 'recover'].includes(dummy.state)) { dummy.invuln = 0; ignoreBlock = true; hitDummy(14, 330, col, hero.x, y, true); ignoreBlock = false; dummy.x = clamp(hero.x + hero.facing * 150, 110, W - 100); sound('heavy'); trauma = Math.min(1, trauma + .3); } else sound('step'); return; } const reach = a.special === 'uppercut' ? 150 : a.special === 'seruduk' ? 190 : 175, dmg = (COMMANDS[a.special] || SHOP_MOVES[a.special]).damage, kb = (COMMANDS[a.special] || SHOP_MOVES[a.special]).knockback; dust(hero.x + hero.facing * 40, 12); if (dx > -30 && dx < reach && near) { const up = a.special === 'uppercut'; if (hitDummy(dmg, kb, col, hero.x, y, up) && up) { dummy.vy = -640; dummy.y -= 2; } sound('heavy'); } else sound('step'); } function comboRank(n) { return Feel.rankOf(n); } // ---- Rasa kena (feel.js): satu pintu untuk hit-stop, getaran, kilat putih, riak, dan K.O. slow-motion ---- // side = yang terkena ('hero' | 'dummy'); dir = arah gaya pukulan (+1 ke kanan); o = { x, y, freeze, shake, lethal }. const FEEL_RIPPLE = { parry: '#8fd0ff', counter: '#ffb15a', ultimate: '#ffe9a8' }; function feelImpact(side, kind, o = {}) { const actor = side === 'hero' ? hero : dummy, f = Feel.hit(kind, o.freeze !== false), k = kind === 'ultimate' ? 1 : (o.shake ?? 1); hitstop = Math.max(hitstop, o.lethal ? Math.max(f.stop, .12) : f.stop); trauma = Math.min(1, trauma + f.trauma * k); if (f.flash) actor.flash = actor.flashMax = f.flash; Feel.kickAt(o.dir, f.kick * Math.min(1, k)); Feel.ripple(o.x ?? actor.x, o.y ?? actor.y - 90, f.ripple, FEEL_RIPPLE[kind]); } // Lawan memukul pemain: efek sama dengan sisi pemain (jenis dari damage, COUNTER bila pemain sedang menyiapkan serangan). function feelHeroHit(damage, options, counter) { const dir = hero.x >= dummy.x ? 1 : -1, kind = Feel.classify(damage, { counter, shake: options.shake }); feelImpact('hero', kind, { dir, x: hero.x - dir * 15, y: hero.y - 80, freeze: options.freeze, shake: options.shake, lethal: hero.hp <= 0 }); damageNumbers.push({ x: hero.x + ((damageNumbers.length % 3) - 1) * 14, y: hero.y - 150, amount: damage, kind, life: .9, maxLife: .9, color: Feel.table.colors.hurt, label: counter ? 'COUNTER' : undefined }); } // K.O.: slow motion + zoom ke yang kalah + vignette. who = 'hero' | 'dummy' | 'both'; final = K.O. penentu pertandingan. function startKoFx(who, final) { Feel.ko.start(who, final); const c = Feel.ko.state.cfg, both = who === 'both', a = who === 'hero' ? hero : dummy, o = a === hero ? dummy : hero; const x = both ? (hero.x + dummy.x) / 2 : a.x, y = both ? CONFIG.groundY : a.y; trauma = Math.max(trauma, c.trauma); hitstop = 0; Feel.kickAt(both || a.x >= o.x ? 1 : -1, Feel.table.hit.ultimate.kick * (final ? 1.4 : 1)); spawnParticles(x, y - 85, '#fff3b0', final ? 46 : 28, final ? 520 : 380); Feel.ripple(x, y - 90, 2, '#ffffff'); if (final) { Feel.ripple(x, y - 90, 2, '#ffd36a', .14); Feel.ripple(x, y - 90, 2, '#ff6a5a', .3); } } function koCamera() { const k = Feel.ko.state; if (!k.on || menuOpen) return null; const z = Feel.ko.zoom(); if (z <= 1.002) return null; const a = k.who === 'hero' ? hero : dummy; return k.who === 'both' ? { z, x: (hero.x + dummy.x) / 2, y: CONFIG.groundY - 90 } : { z, x: a.x, y: a.y - 90 }; } // Mode non-versus (latihan): tidak ada event ronde, jadi K.O. dideteksi dari peralihan status. const koSeen = { dummy: false, hero: false }; function feelWatchKo() { const d = !!dummy.ko, h = hero.hp <= 0; if (match?.mode !== 'versus') { if (d && !koSeen.dummy) startKoFx('dummy', false); else if (h && !koSeen.hero) startKoFx('hero', false); } koSeen.dummy = d; koSeen.hero = h; } // Kilat putih pada sprite yang kena: sprite digambar ulang putih (hanya piksel sprite) dengan alpha menurun. const flashCv = document.createElement('canvas'), flashCx = flashCv.getContext('2d'); function drawFlash(actor, img, r, dx, dy) { flashCv.width = r.w; flashCv.height = r.h; flashCx.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h); flashCx.globalCompositeOperation = 'source-atop'; flashCx.fillStyle = '#fff'; flashCx.fillRect(0, 0, r.w, r.h); flashCx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = Math.min(1, actor.flash / (actor.flashMax || actor.flash)) * .85 * (window.Access?.motion ?? 1); ctx.drawImage(flashCv, dx, dy, r.w, r.h); ctx.globalAlpha = 1; // gerak dikurangi: tanpa kilat putih } // ---- Guard (hold L), parry (tap L just before a hit), throw (J), P2, CPU guard ---- // Blok otomatis (access.js): serangan dari depan ditahan lewat jalur blok biasa bila pemain di tanah, tidak beraksi, dan guard belum pecah. // Sesudahnya hero.autoGuard menahan sikap blok 0,3 dtk (lihat step) supaya bingkai blok terlihat dan pukulan berantai ikut ditahan. const autoGuardReady = () => !!window.Access?.autoBlock && hero.grounded && hero.hurtTime <= 0 && (hero.guardBreak || 0) <= 0 && hero.doublePose <= 0 && (dummy.x - hero.x) * hero.facing > 0; function heroGuard(damage, options) { const auto = !hero.blocking && !(hero.parry > 0) && autoGuardReady(); if (!(hero.blocking || hero.parry > 0 || auto) || hero.action || options?.unblockable) return false; if (auto || hero.autoGuard > 0) hero.autoGuard = .3; if (hero.parry > 0) { hero.parry = 0; hero.invuln = Math.max(hero.invuln, .35); announce('PARRY!', .6); sound('parry'); window.Meta?.shout?.('Parry!'); feelImpact('hero', 'parry', { dir: -hero.facing, x: hero.x + hero.facing * 40, y: hero.y - 100 }); damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'PARRY', kind: 'parry', life: .8, maxLife: .8, color: Feel.table.colors.parry }); spawnParticles(hero.x + hero.facing * 40, hero.y - 100, '#cfe9ff', 22, 320); if (!['down', 'recover'].includes(dummy.state)) { dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = -.3; dummy.vx = -hero.facing * 120; } window.MatchStats?.parry(); window.Meta?.event?.('parry'); return true; } const chip = Math.max(1, Math.round(damage * .2)); hero.hp = Math.max(1, hero.hp - chip); hero.guard += damage * 1.6; hero.vx = -hero.facing * 90; window.MatchStats?.block(); spawnParticles(hero.x + hero.facing * 38, hero.y - 90, '#ffd36a', 10, 200); sound('guard'); feelImpact('hero', 'guard', { dir: -hero.facing, x: hero.x + hero.facing * 38, y: hero.y - 90 }); damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard }); if (hero.guard >= 60) { hero.guard = 0; hero.guardBreak = 1.2; hero.blocking = false; hero.hurtTime = .7; state('hurt'); announce('GUARD BREAK', .7); } return true; } function startThrow() { if (!canAct() || hero.action || hero.doublePose > 0 || !hero.grounded || (hero.throwCd || 0) > 0) return false; hero.throwCd = 3; hero.action = { type: 'attack', name: 'attack2', index: 3, t: 0, duration: .5, hit: false, queued: 0, fired: false, hitAt: .3, special: 'throw', color: '#ffb36a' }; hero.runDirection = 0; state('attack2'); announce('LEMPAR!', .5); window.Meta?.shout?.('Lempar!'); return true; } // P2 (local two-player): the second fighter is driven by these keys instead of the CPU. const P2_KEYS = ['arrowleft', 'arrowright', 'arrowup', 'n', 'm', ',', '.']; let p2Chain = { i: 0, t: -9 }; function p2Press(key) { keys2.add(key); if (menuOpen || paused || (match && match.phase !== 'fight') || dummy.state === 'hurt' || dummy.state === 'down' || dummy.state === 'recover') return; if (key === 'arrowup' && dummy.y >= CONFIG.groundY - 1 && !dummy.action) { dummy.vy = -CONFIG.jumpSpeed; dummy.y -= 1; dummy.jumps = 1; dummy.state = 'jump'; dummy.stateTime = 0; sound('jump'); return; } if (key === 'n') { const i = time - p2Chain.t < .7 && p2Chain.i < 3 ? p2Chain.i + 1 : 1; if (startEnemyAction('attack', i)) p2Chain = { i, t: time }; return; } if (key === 'm') startEnemyAction('skill1'); if (key === ',') startEnemyAction('skill2'); if (key === '.') startEnemyAction('ultimate'); } function updateP2(dt) { const dir = (keys2.has('arrowright') ? 1 : 0) - (keys2.has('arrowleft') ? 1 : 0), grounded = dummy.y >= CONFIG.groundY - 1; if (hero.hp <= 0 || (match && match.phase !== 'fight')) { dummy.vx = approach(dummy.vx, 0, dt * 1500); if (grounded) dummy.state = 'idle'; return; } if (!grounded) { dummy.state = 'jump'; if (dir) dummy.vx = approach(dummy.vx, dir * 260, dt * 1600); return; } dummy.facing = hero.x >= dummy.x ? 1 : -1; dummy.vx = approach(dummy.vx, dir * CONFIG.walkSpeed * passiveMod(dummy, 'walk'), dt * 2800); dummy.state = dir ? 'walk' : 'idle'; dummy.walkPhase = (dummy.walkPhase || 0) + Math.abs(dummy.vx) * dt / 148; } function forceCommand(id) { const c = COMMANDS[id]; if (!c) return false; inputLog.length = 0; const f = hero.facing > 0 ? 'd' : 'a'; if (id === 'uppercut') inputLog.push({ k: 's', t: time - .1 }, { k: 's', t: time }); else inputLog.push({ k: 's', t: time - .1 }, { k: f, t: time }); return startCommand(); } const CPU_BLOCK = { easy: .05, medium: .14, hard: .24, excellent: .32 }; function release(key) { key = key.toLowerCase(); keys.delete(key); keys2.delete(key); if ((key === 'a' && hero.runDirection === -1) || (key === 'd' && hero.runDirection === 1)) hero.runDirection = 0; } function clearInput() { keys.clear(); keys2.clear(); hero.runDirection = 0; taps.a = taps.d = taps.s = -10; } function resetCombat() { systemAnnouncer?.clear(); stopUltimateVoice(); Object.assign(hero, { x: 350, y: CONFIG.groundY, vx: 0, vy: 0, facing: 1, grounded: true, jumps: 0, state: 'idle', animTime: 0, walkPhase: 0, smokeDistance:0, action: null, runDirection: 0, hp: BALANCE.heroMax, koTime:0, invuln: 0, hurtTime: 0, doublePose: 0, rollDuration: CONFIG.rollDuration, rollBuffer: null, airTime: 0, cooldowns: { skill1: 0, skill2: 0, ultimate: 0 } }); hero.spCd = hero.shopCd = hero.shield = hero.autoGuard = 0; inputLog.length = 0; pcarry.player = pcarry.enemy = pregen.player = pregen.enemy = 0; hero.lastHurt = dummy.lastHurt = -99; Object.assign(dummy, { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0 }); if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();} fenrCutin=null;hudIdentity=''; particles.length = effects.length = projectiles.length = damageNumbers.length = 0; Feel.reset(); hero.flash = dummy.flash = 0; koSeen.dummy = koSeen.hero = false; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25); bwReset(); squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null; for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0; } function reset() { if(Rules)match=Rules.create(match?.mode||'training'); window.MatchStats?.reset(match?.mode==='versus'); resetCombat();if(match?.mode==='versus'){announceTimer=0;emitRoundCue('round',1);} } function hitDummy(damage, power, color, sourceX, impactY, knockdown = false, freezeWorld = true, shake = 1) { if (menuOpen || (match && match.phase!=='fight') || dummy.invuln > 0 || dummy.state === 'down' || dummy.state === 'recover') return false; if (dummy.action?.guard && shellGuard(dummy)) return false; if (damage > 0) damage = Math.max(1, passiveDamage(hero, dummy, damage, hitKind === 'basic')); dummy.lastHurt = time; const dir = sourceX <= dummy.x ? 1 : -1; if (!ignoreBlock && F && aiEnabled && !p2Mode && match?.mode === 'versus' && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk') && !knockdown && random() < (CPU_BLOCK[difficulty] ?? .1) * passiveMod(dummy, 'parry')) { const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip; window.MatchStats?.hit(hitSource(), false, 0, hero.action); spawnParticles(dummy.x - dir * 30, impactY, '#ffd36a', 8, 180); damageNumbers.push({ x: dummy.x, y: impactY - 40, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard }); sound('step'); feelImpact('dummy', 'guard', { dir, x: dummy.x - dir * 30, y: impactY }); return true; } // Counter hit: striking a rival in their wind-up deals extra; long chains scale down so none loops forever. const counter = !!(dummy.action && !dummy.action.fired && dummy.action.type === 'attack'); if (counter) { damage = Math.round(damage * 1.25); if (damage > 1) announce('COUNTER!', .5); } if (comboHits >= 5) damage = Math.max(1, Math.round(damage * Math.max(.55, 1 - .07 * (comboHits - 4)))); dummy.damage += damage; totalDamage += damage; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += damage; comboTimer = 2.3; window.MatchStats?.hit(hitSource(), counter, comboHits, hero.action); // Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's), // so a re-started chain cannot loop it to K.O. Easy has no limit. const cap = F && aiEnabled && match?.mode === 'versus' ? cpuProfile().comboCap : 0; dummy.stunHits = dummy.state === 'hurt' ? (dummy.stunHits || 0) + 1 : 1; if (cap && dummy.stunHits >= cap) dummy.invuln = .5 + cpuProfile().immunity; dummy.flash = 0; dummy.vx = dir * power; dummy.stateTime = 0; dummy.state = 'hurt'; if (dummy.y >= CONFIG.groundY - 1) dummy.juggle = 0; else if ((dummy.juggle = (dummy.juggle || 0) + 1) <= 4) dummy.vy = Math.min(dummy.vy, -260); dummy.action=null; if(F&&dummy.transformPending>0)F.end(dummy); const kind = Feel.classify(damage, { counter, shake }), big = kind === 'heavy' || kind === 'ultimate' || kind === 'counter'; feelImpact('dummy', kind, { dir, x: dummy.x - dir * 15, y: impactY, freeze: freezeWorld, shake, lethal: dummy.damage >= BALANCE.dummyMax }); damageNumbers.push({ x: dummy.x + (random() - .5) * 20, y: impactY - 20, amount: damage, kind, life: .9, maxLife: .9, color, label: counter ? 'COUNTER' : undefined }); spawnParticles(dummy.x - dir * 21, impactY, color, big ? 22 : 14, big ? 320 : 220); effects.push({ type: 'impact', x: dummy.x - dir * 15, y: impactY, life: .2, maxLife: .2, color, heavy: big }); if (dummy.damage >= BALANCE.dummyMax || knockdown) { dummy.ko=dummy.damage>=BALANCE.dummyMax; dummy.state = 'down'; dummy.vy = -330; dummy.vx = dir * 290; dummy.fallDirection = dir; announce(dummy.ko?'K.O.':'KNOCKDOWN', .9); } sound(damage >= 30 ? 'heavy' : 'hit'); return true; } function attackRange(index) { if(selectedCharacter==='fenr')return F.move(hero,'attack',index).reach; if(KITS[selectedCharacter])return KITS[selectedCharacter].move('attack',index).reach; const name = `attack${index}`, data = manifest?.combat?.hitRanges?.[name]; const measured = metrics?.states?.[name]?.frames; return typeof data === 'number' ? data : data?.reach || (measured?.length ? Math.max(...measured.map(f => f.bounds.right)) : [122, 143, 176][index - 1]); } function emitter(name) { const measured = metrics?.emitters?.[name]; if (measured && Number.isFinite(measured.x) && Number.isFinite(measured.y)) return { x: hero.x + hero.facing * measured.x, y: hero.y + measured.y }; // Fallbacks measured on the first atlas, not inferred from the cell dimensions. const fallback = { attack1: [61, -105], attack2: [27, -157], attack3: [47, -102], skill1: [89, -113], skill2: [30, 0], ultimate: [88, -93] }[name] || [50, -100]; return { x: hero.x + hero.facing * fallback[0], y: hero.y + fallback[1] }; } function meleeHit(index) { if(selectedCharacter==='fenr'){fenrStrike(hero,F.move(hero,'attack',index));return;} if(selectedCharacter==='mira'){miraStrike(hero,M.move('attack',index));return;} if(selectedCharacter==='cora'){coraStrike(hero,C.move('attack',index));return;} if(selectedCharacter==='naja'){najaStrike(hero,N.move('attack',index));return;} if(selectedCharacter==='haldor'){haldorStrike(hero,HD.move('attack',index));return;} if(selectedCharacter==='zanni'){zanniStrike(hero,Z.move('attack',index));return;} if(selectedCharacter==='isolde'){isoldeStrike(hero,IS.move('attack',index));return;} if(selectedCharacter==='rhea'){rheaStrike(hero,RH.move('attack',index));return;} if(selectedCharacter==='solan'){solanStrike(hero,SO.move('attack',index));return;} if(selectedCharacter==='nib'){nibStrike(hero,NB.move('attack',index));return;} if(selectedCharacter==='edda'){eddaStrike(hero,ED.move('attack',index));return;} if(selectedCharacter==='browo'){browoStrike(hero,BW.move('attack',index));return;} if(CF[selectedCharacter]){cfStrike(hero,CF[selectedCharacter],KITS[selectedCharacter].move('attack',index));return;} const reach = attackRange(index), dx = (dummy.x - hero.x) * hero.facing, y = emitter(`attack${index}`).y; effects.push({ type: 'slash', x: hero.x + hero.facing * 48, y: y + 5, life: .22, maxLife: .22, facing: hero.facing, index, color: index === 3 ? '#ffe4a5' : '#cffbf0' }); if (dx > -24 && dx < reach + 27 && Math.abs(dummy.y - hero.y) < 113) { const connected = hitDummy(BALANCE.combo[index-1].damage, BALANCE.combo[index-1].knockback, index === 3 ? '#ffdf92' : '#c6fff0', hero.x, y); if (connected) for (const name of Object.keys(cooldownMax)) { if(hero.cooldowns[name]>0) rechargePulse[name]=.22; hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - cooldownMax[name] * BALANCE.basicCooldownRefund); } } else sound('step'); } function fireSkill(action) { if(selectedCharacter==='fenr'){fenrStrike(hero,action);return;} if(selectedCharacter==='mira'){miraStrike(hero,action);return;} if(selectedCharacter==='cora'){coraStrike(hero,action);return;} if(selectedCharacter==='naja'){najaStrike(hero,action);return;} if(selectedCharacter==='haldor'){haldorStrike(hero,action);return;} if(selectedCharacter==='zanni'){zanniStrike(hero,action);return;} if(selectedCharacter==='isolde'){isoldeStrike(hero,action);return;} if(selectedCharacter==='rhea'){rheaStrike(hero,action);return;} if(selectedCharacter==='solan'){solanStrike(hero,action);return;} if(selectedCharacter==='nib'){nibStrike(hero,action);return;} if(selectedCharacter==='edda'){eddaStrike(hero,action);return;} if(selectedCharacter==='browo'){browoStrike(hero,action);return;} if(CF[selectedCharacter]){cfStrike(hero,CF[selectedCharacter],action);return;} const name = action.type, origin = emitter(name); if (name === 'skill1') { projectiles.push({ x: origin.x, y: origin.y, vx: hero.facing * 920, life: 1.6, facing: hero.facing }); effects.push({ type: 'muzzle', x: origin.x, y: origin.y, life: .25, maxLife: .25 }); spawnParticles(origin.x, origin.y, '#a9f8ef', 12, 140); } if (name === 'skill2') { const x = origin.x; effects.push({ type: 'slam', x, y: CONFIG.groundY - 2, life: .8, maxLife: .8 }); spawnParticles(x, CONFIG.groundY - 8, '#c7eafd', 40, 370); trauma = Math.min(1, trauma + .35); sound('heavy'); if (Math.abs(dummy.x - x) < BALANCE.skill2.radius && dummy.y > CONFIG.groundY - 125) hitDummy(BALANCE.skill2.damage, BALANCE.skill2.knockback, '#bfe9ff', hero.x, dummy.y - 65); } } function startSquadron(actor = hero) { const target=actor===hero?dummy:hero; const centerX = clamp(actor.x, 220, W - 220), baseY = clamp(actor.y, 480, CONFIG.groundY); const offsets = [[-150,-240],[-55,-310],[65,-305],[160,-235]]; const group = { t: 0, owner:actor===hero?'player':'enemy', facing: actor.facing, casterX: actor.x, targeted: (target.x - actor.x) * actor.facing >= 0, shots: 0, phase: 'cutin', drones: offsets.map(([dx,dy], i) => ({ id: i, x: centerX + actor.facing * (dx - 360), y: -130 - i * 55, startX: centerX + actor.facing * (dx - 360), startY: -130 - i * 55, targetX: centerX + actor.facing * dx, targetY: baseY + dy, delay: .46 + i * .10, fired: false, visible: false, alpha: 1, angle: 0 })) }; if(actor===hero)squad=group;else enemySquad=group; cinematic = .9; announceTimer = 0; startUltimateVoice('arco',actor); } function updateSquadron(dt, s = squad) { if (!s) return; const target=s.owner==='enemy'?hero:dummy;s.t += dt; s.phase = s.t < SQUAD.cutinDuration ? 'cutin' : s.t < SQUAD.firstShot ? 'lock-on' : s.t < SQUAD.departure ? 'firing' : 'departing'; for (const d of s.drones) { const entry = clamp((s.t - d.delay) / .60, 0, 1), ease = 1 - Math.pow(1 - entry, 3); d.visible = s.t >= d.delay; d.x = d.startX + (d.targetX - d.startX) * ease; d.y = d.startY + (d.targetY - d.startY) * ease; if (entry === 1) d.y += Math.sin((s.t - d.delay) * 5 + d.id) * 2; if (s.t > SQUAD.departure) { const exit = clamp((s.t - SQUAD.departure) / .6, 0, 1); d.x += s.facing * exit * exit * 280; d.y -= exit * exit * 470; d.alpha = 1 - exit; } const targetX = s.targeted ? target.x : (s.facing === 1 ? W + 50 : -50); const targetY = s.targeted ? target.y - 85 : CONFIG.groundY - 90; d.angle = s.facing * Math.atan2(targetY - d.y, Math.abs(targetX - d.x)); if (!d.fired && s.t >= SQUAD.firstShot + d.id * SQUAD.shotGap) { d.fired = true; s.shots++; const origin = droneMuzzle(d, s.facing); effects.push({ type: 'drone-laser', owner:s.owner, x: origin.x, y: origin.y, endX: targetX, endY: targetY, life: .65, maxLife: .65, droneId: d.id }); effects.push({ type: 'muzzle', x: origin.x, y: origin.y, life: .19, maxLife: .19, color: '#fff0be' }); spawnParticles(origin.x, origin.y, '#a8fff0', 8, 90); trauma = Math.max(trauma, d.id === 3 ? .5 : .23); sound('cast'); if (s.targeted && (target.x - s.casterX) * s.facing >= -30) { if(s.owner==='enemy')window.__game.receiveHit(BALANCE.ultimate.damagePerDrone,{freeze:false,projectile:true}); else hitDummy(BALANCE.ultimate.damagePerDrone, d.id === 3 ? BALANCE.ultimate.knockback : 45, '#ffe2a0', s.casterX, targetY, false, false); } } } for (const e of effects) { if (e.type !== 'drone-laser'||e.owner!==s.owner) continue; const origin = droneMuzzle(s.drones[e.droneId], s.facing); e.x = origin.x; e.y = origin.y; if (s.targeted) { e.endX = target.x; e.endY = target.y - 85; } } if (s.t >= SQUAD.duration) { if(s.owner==='enemy')enemySquad=null;else squad=null;if (voiceActive && voiceKind === 'arco' && (voiceActor===dummy)===(s.owner==='enemy')) stopUltimateVoice(); } } // MIRA Rocket Parade: 12 rockets from alternating pods, one 4-damage hit each (48 total), no global hit-stop. function startRocketParade(actor = hero) { const target = actor === hero ? dummy : hero, P = M.parade; const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, casterX: actor.x, targeted: (target.x - actor.x) * actor.facing >= 0, hits: 0, launched: 0, exploded: 0, phase: 'cutin', rockets: Array.from({ length: M.balance.rockets }, (_, i) => ({ id: i, pod: i % 2, launchAt: P.launchStart + i * P.launchGap, impactAt: P.impactStart + i * P.impactGap, spread: ((i * 7) % 11 - 5) * 9, lift: 250 + ((i * 5) % 4) * 38, state: 'loaded', locked: false, x: 0, y: 0, sx: 0, sy: 0, tx: 0, ty: 0, angle: 0 })) }; if (actor === hero) parade = group; else enemyParade = group; cinematic = .9; announceTimer = 0; startUltimateVoice('mira', actor); } function paradeAim(s, r, target) { if (s.targeted) return { x: target.x + r.spread, y: target.y - 70 - Math.abs(r.spread) * .6 }; return { x: clamp(s.casterX + s.facing * (380 + r.id * 22), 40, W - 40), y: CONFIG.groundY - 14 }; } function updateParade(dt, s) { if (!s) return; const actor = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, P = M.parade; const pods = window.MIRA_METRICS?.emitters?.ultimatePods?.pods || [{ x: -34, y: -168 }, { x: 71, y: -147 }]; s.t += dt; for (const r of s.rockets) { if (r.state === 'loaded' && s.t >= r.launchAt) { // Rockets leave the pods where the robot stands now; the salvo keeps its own cast direction and target lock. const pod = pods[r.pod % pods.length]; r.state = 'flying'; r.sx = r.x = actor.x + actor.facing * pod.x; r.sy = r.y = actor.y + pod.y; s.launched++; effects.push({ type: 'muzzle', x: r.sx, y: r.sy, life: .16, maxLife: .16, color: '#ffe38a' }); if (r.id % 3 === 0) sound('cast'); } if (r.state !== 'flying') continue; const u = clamp((s.t - r.launchAt) / (r.impactAt - r.launchAt), 0, 1); if (!r.locked) { const aim = paradeAim(s, r, target); r.tx = aim.x; r.ty = aim.y; r.locked = u >= .8; } const cx = r.sx + (r.tx - r.sx) * .35, cy = Math.min(r.sy, r.ty) - r.lift, inv = 1 - u; const nx = inv * inv * r.sx + 2 * inv * u * cx + u * u * r.tx, ny = inv * inv * r.sy + 2 * inv * u * cy + u * u * r.ty; if (nx !== r.x || ny !== r.y) r.angle = Math.atan2(ny - r.y, nx - r.x); r.x = nx; r.y = ny; if ((stepCount + r.id) % 4 === 0) particles.push({ kind: 'dust', x: r.x - Math.cos(r.angle) * 24, y: r.y - Math.sin(r.angle) * 24, vx: -Math.cos(r.angle) * 40, vy: -Math.sin(r.angle) * 40, life: .32, maxLife: .32, size: 3 + random() * 3, color: r.id % 2 ? '#ffc7ea' : '#ffe39a' }); if (u < 1) continue; r.state = 'done'; s.exploded++; effects.push({ type: 'mira-fx', asset: 'mira-burst', x: r.x, y: r.y, facing: 1, life: .34, maxLife: .34, size: 92 }); spawnParticles(r.x, r.y, '#ffc6ec', 6, 150); if (s.targeted && Math.abs(target.x - r.x) < 85 && Math.abs(target.y - 90 - r.y) < 115) { const last = r.id === s.rockets.length - 1; const connected = s.owner === 'enemy' ? window.__game.receiveHit(M.balance.rocketDamage, { freeze: false, projectile: true, shake: .3 }) : hitDummy(M.balance.rocketDamage, last ? 190 : 30, '#ffd86b', s.casterX, r.y, false, false, .3); if (connected) s.hits++; } } s.phase = s.t < P.cutinDuration ? 'cutin' : s.exploded < s.rockets.length ? 'barrage' : 'clearing'; if (s.t >= P.duration) { if (s.owner === 'enemy') enemyParade = null; else parade = null; } } function startSummon(actor) { const id = actor === hero ? selectedCharacter : opponentCharacter; if (id === 'cora') startMurmuration(actor); else if (id === 'naja') startSerpent(actor); else if (id === 'haldor') startQuake(actor); else if (id === 'zanni') startFinale(actor); else if (id === 'isolde') startSkyfall(actor); else if (id === 'rhea') startOrrery(actor); else if (id === 'solan' || id === 'browo' || CF[id]) startSunroar(actor); else if (id === 'nib') startDelivery(actor); else if (id === 'edda') startTortoise(actor); else startRocketParade(actor); } // CORA Night Murmuration: three raven passes sweep forward from behind her wings at three heights, // one 16-damage hit each (48 total), no global hit-stop. A pass hits once, when its lead raven crosses the target. function startMurmuration(actor = hero) { const U = C.murmuration; const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, casterX: actor.x, hits: 0, phase: 'cutin', passes: U.passStart.map((at, id) => ({ id, at, y: CONFIG.groundY + U.passHeights[id], front: actor.x - actor.facing * 140, state: 'waiting', hit: false })) }; if (actor === hero) flock = group; else enemyFlock = group; cinematic = .9; announceTimer = 0; startUltimateVoice('cora', actor); } function updateMurmuration(dt, s) { if (!s) return; const target = s.owner === 'enemy' ? hero : dummy, U = C.murmuration, tail = (U.ravens - 1) * U.spacing; s.t += dt; for (const p of s.passes) { if (p.state === 'waiting' && s.t >= p.at) { p.state = 'flying'; sound('cast'); } if (p.state !== 'flying') continue; const old = p.front; p.front += s.facing * U.speed * dt; if (!p.hit && (target.x - old) * s.facing >= 0 && (target.x - p.front) * s.facing < 0) { // Only a target in front of the cast whose body overlaps this pass height is struck; a jump can clear the low pass. p.hit = true; if ((target.x - s.casterX) * s.facing >= -30 && p.y > target.y - 200 && p.y < target.y + 20) { const connected = s.owner === 'enemy' ? window.__game.receiveHit(C.balance.passDamage, { freeze: false, projectile: true, shake: .6 }) : hitDummy(C.balance.passDamage, p.id === s.passes.length - 1 ? 190 : 60, '#d9c2ff', s.casterX, p.y, false, false, .6); if (connected) { s.hits++; spawnParticles(target.x, p.y, '#3a2d58', 10, 180); } } } if ((stepCount + p.id) % 5 === 0) particles.push({ kind: 'dust', x: p.front - s.facing * random() * tail, y: p.y + (random() - .5) * 40, vx: -s.facing * 30, vy: 30, life: .5, maxLife: .5, size: 2 + random() * 2, color: '#3a2d58' }); const tailX = p.front - s.facing * tail; if (s.facing > 0 ? tailX > W + 80 : tailX < -80) p.state = 'done'; } s.phase = s.t < U.cutinDuration ? 'cutin' : s.passes.some(p => p.state !== 'done') ? 'sweeping' : 'clearing'; if (s.t >= U.duration) { if (s.owner === 'enemy') enemyFlock = null; else flock = null; } } // NAJA Dune Serpent: a sand ripple leaves her palm, chases the rival and locks `telegraph` seconds before each strike, then a giant // sand cobra erupts there. Three strikes x 16 (48), no global hit-stop. Each ripple starts tracking when the previous // one locks. The column is too tall to jump; stepping out of the locked ripple dodges it. function startSerpent(actor = hero) { const U = N.serpent, target = actor === hero ? dummy : hero; const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, casterX: actor.x, hits: 0, phase: 'cutin', strikes: U.strikes.map((at, id) => ({ id, at, lockAt: at - U.telegraph, appearAt: id ? U.strikes[id - 1] - U.telegraph : U.follow, x: id ? target.x : clamp(actor.x + actor.facing * 90, 60, W - 60), state: 'waiting', lockedAt: 0, hit: false })) }; if (actor === hero) serpent = group; else enemySerpent = group; cinematic = .9; announceTimer = 0; startUltimateVoice('naja', actor); } function updateSerpent(dt, s) { if (!s) return; const target = s.owner === 'enemy' ? hero : dummy, U = N.serpent; s.t += dt; for (const k of s.strikes) { if (k.state === 'waiting' && s.t >= k.appearAt) { k.state = 'tracking'; if (k.id) k.x = s.strikes[k.id - 1].x; } if (k.state === 'tracking') { k.x = clamp(approach(k.x, target.x, U.speed * dt), 60, W - 60); if (s.t >= k.lockAt) { k.state = 'locked'; k.lockedAt = time; sound('step'); } } if (k.state === 'locked' && s.t >= k.at) { k.state = 'erupting'; trauma = Math.min(1, trauma + .22); sound('heavy'); spawnParticles(k.x, CONFIG.groundY - 12, '#e3bf78', 22, 300, 'dust'); spawnParticles(k.x, CONFIG.groundY - 60, '#f6dc9c', 12, 260); if (!k.hit && Math.abs(target.x - k.x) < U.radius && CONFIG.groundY - target.y < U.height) { k.hit = true; const last = k.id === s.strikes.length - 1; const connected = s.owner === 'enemy' ? window.__game.receiveHit(N.balance.biteDamage, { freeze: false, projectile: true, shake: .6 }) : hitDummy(N.balance.biteDamage, last ? 190 : 60, '#ffd98a', s.casterX, target.y - 100, false, false, .6); if (connected) s.hits++; } } if (k.state === 'erupting' && s.t >= k.at + U.rise) k.state = 'done'; } s.phase = s.t < U.cutinDuration ? 'cutin' : s.strikes.some(k => k.state !== 'done') ? 'striking' : 'clearing'; if (s.t >= U.duration) { if (s.owner === 'enemy') enemySerpent = null; else serpent = null; } } // HALDOR Forge Quake: three hammer slams at the crater in front of him; each sends a molten shockwave along the // floor both ways (ground projectiles, 16 each, 48 total). HALDOR is free after the 0.5 s cast; a jump clears a wave. function startQuake(actor = hero) { const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, x: clamp(actor.x + actor.facing * 70, 60, W - 60), slams: 0, phase: 'cutin' }; if (actor === hero) quake = group; else enemyQuake = group; cinematic = .9; announceTimer = 0; startUltimateVoice('haldor', actor); } function updateQuake(dt, s) { if (!s) return; const U = HD.quake; s.t += dt; while (s.slams < U.slams.length && s.t >= U.slams[s.slams]) { const last = s.slams === U.slams.length - 1; s.slams++; effects.push({ type: 'haldor-fx', asset: 'haldor-splash', x: s.x, y: CONFIG.groundY - 24, facing: s.facing, life: .35, maxLife: .35, size: 170 }); spawnParticles(s.x, CONFIG.groundY - 10, '#ff8a2a', 20, 320); dust(s.x, 16); trauma = Math.min(1, trauma + .3); sound('heavy'); for (const dir of [-1, 1]) projectiles.push({ x: s.x + dir * 30, y: CONFIG.groundY - 22, vx: dir * U.speed, life: 1.9, facing: dir, owner: s.owner, damage: HD.balance.quakeDamage, knockback: last ? 190 : 60, asset: 'haldor-quake', size: 124, color: '#ffb35c' }); } s.phase = s.t < U.cutinDuration ? 'cutin' : s.slams < U.slams.length ? 'slamming' : 'rolling'; if (s.t >= U.duration) { if (s.owner === 'enemy') enemyQuake = null; else quake = null; } } // ZANNI Grand Finale: three giant bladed rings flung forward from his hand 0.38 s apart; each flies to the arena edge // and boomerangs back to ZANNI (boomerang projectiles, one 16-damage hit per ring, 48 total). ZANNI is free after the // 0.5 s cast; a rival behind him at the throw is safe, a jump clears a ring on the way out. function startFinale(actor = hero) { const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, throws: 0, phase: 'cutin' }; if (actor === hero) finale = group; else enemyFinale = group; cinematic = .9; announceTimer = 0; startUltimateVoice('zanni', actor); } function updateFinale(dt, s) { if (!s) return; const U = Z.finale, a = s.owner === 'enemy' ? dummy : hero, em = window.ZANNI_METRICS?.emitters?.ultimate; s.t += dt; while (s.throws < U.throws.length && s.t >= U.throws[s.throws]) { const last = s.throws === U.throws.length - 1; s.throws++; const x = a.x + s.facing * (em?.x ?? 90), y = clamp(a.y + (em?.y ?? -95), a.y - 130, a.y - 70); projectiles.push({ x, y, vx: s.facing * U.speed, life: 4, facing: s.facing, owner: s.owner, damage: Z.balance.finaleDamage, knockback: last ? 190 : 50, asset: 'zanni-bigring', size: U.size, color: '#e4f36a', boomerang: true, range: Math.max(120, s.facing > 0 ? W - 60 - x : x - 60), travel: 0, spin: 16, freeze: false }); effects.push({ type: 'zanni-fx', asset: 'zanni-confetti', x: x + s.facing * 20, y, facing: s.facing, life: .35, maxLife: .35, size: 170 }); spawnParticles(x, y, '#e4f36a', 14, 260); sound('cast'); } s.phase = s.t < U.cutinDuration ? 'cutin' : s.throws < U.throws.length ? 'throwing' : 'returning'; if (s.t >= U.duration && !projectiles.some(p => p.asset === 'zanni-bigring' && p.owner === s.owner)) { if (s.owner === 'enemy') enemyFinale = null; else finale = null; } } // ISOLDE Skyfall Lances: three giant ice lances dive from the sky 0.38 s apart, each aimed at the rival's feet when it // is called (16 each, 48 total), with a small shatter where it lands. ISOLDE is free after the 0.5 s cast; a standing // rival takes all three, a moving one makes them land behind. function startSkyfall(actor = hero) { const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, calls: 0, phase: 'cutin' }; if (actor === hero) skyfall = group; else enemySkyfall = group; cinematic = .9; announceTimer = 0; startUltimateVoice('isolde', actor); } function updateSkyfall(dt, s) { if (!s) return; const U = IS.skyfall, target = s.owner === 'enemy' ? hero : dummy; s.t += dt; while (s.calls < U.calls.length && s.t >= U.calls[s.calls]) { const last = s.calls === U.calls.length - 1; s.calls++; const tx = clamp(target.x, 40, W - 40), land = CONFIG.groundY - 40, x = tx - s.facing * U.back, y = land - U.height; projectiles.push({ x, y, vx: s.facing * U.back / U.flight, vy: U.height / U.flight, life: U.flight + .3, facing: s.facing, owner: s.owner, damage: IS.balance.lanceDamage, knockback: last ? 190 : 50, asset: 'isolde-skyfall', size: U.size, color: '#bfe6ff', landY: land, shatter: U.shatter, freeze: false }); spawnParticles(x, y + 40, '#dff3ff', 8, 160); } s.phase = s.t < U.cutinDuration ? 'cutin' : s.calls < U.calls.length ? 'calling' : 'falling'; if (s.t >= U.duration && !projectiles.some(p => p.asset === 'isolde-skyfall' && p.owner === s.owner)) { if (s.owner === 'enemy') enemySkyfall = null; else skyfall = null; } } // Where a Skyfall lance lands: an ice shatter that still catches a rival within `shatter` px. function lanceShatter(p) { effects.push({ type: 'isolde-fx', asset: 'isolde-shatter', x: p.x, y: CONFIG.groundY - 40, facing: p.facing, life: .4, maxLife: .4, size: 190 }); spawnParticles(p.x, CONFIG.groundY - 20, '#dff3ff', 18, 280); dust(p.x, 14); sound('heavy'); trauma = Math.min(1, trauma + .18); if (p.owner === 'enemy') { if (Math.abs(hero.x - p.x) < p.shatter && hero.y > CONFIG.groundY - 70) window.__game.receiveHit(p.damage, { projectile: true, freeze: false }); } else if (Math.abs(dummy.x - p.x) < p.shatter && dummy.y > CONFIG.groundY - 70) hitDummy(p.damage, p.knockback, p.color, p.x - p.facing * 20, CONFIG.groundY - 60, false, false); } // RHEA Grand Orrery: three giant planets released 0.38 s apart, each making one lap around RHEA (orbit projectiles, // one 16-damage hit each, 48 total). RHEA is free after the 0.5 s cast; the planets follow her, and a rival beyond // the orbit is safe. function startOrrery(actor = hero) { const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, released: 0, phase: 'cutin' }; if (actor === hero) orrery = group; else enemyOrrery = group; cinematic = .9; announceTimer = 0; startUltimateVoice('rhea', actor); } function updateOrrery(dt, s) { if (!s) return; const U = RH.orrery, a = s.owner === 'enemy' ? dummy : hero; s.t += dt; while (s.released < U.releases.length && s.t >= U.releases[s.released]) { const last = s.released === U.releases.length - 1; s.released++; projectiles.push({ x: a.x + s.facing * U.rx, y: a.y - 90, vx: 0, life: U.lap + .2, facing: s.facing, owner: s.owner, damage: RH.balance.planetDamage, knockback: last ? 190 : 40, asset: 'rhea-planet', size: U.size, color: '#f2c77a', orbit: true, angle: 0, lap: U.lap, rx: U.rx, ry: U.ry, spin: 2, freeze: false }); effects.push({ type: 'rhea-fx', asset: 'rhea-burst', x: a.x + s.facing * U.rx, y: a.y - 90, facing: s.facing, life: .3, maxLife: .3, size: 150 }); spawnParticles(a.x, a.y - 90, '#f2c77a', 12, 220); sound('cast'); } s.phase = s.t < U.cutinDuration ? 'cutin' : s.released < U.releases.length ? 'releasing' : 'orbiting'; if (s.t >= U.duration && !projectiles.some(p => p.asset === 'rhea-planet' && p.owner === s.owner)) { if (s.owner === 'enemy') enemyOrrery = null; else orrery = null; } } // RHEA Gravity Well collapsing: everything within its radius on the floor takes the hit. function wellBurst(p) { effects.push({ type: 'rhea-fx', asset: 'rhea-burst', x: p.x, y: p.y, facing: p.facing, life: .35, maxLife: .35, size: 200 }); spawnParticles(p.x, p.y, '#e8b0c0', 18, 260); sound('heavy'); trauma = Math.min(1, trauma + .15); if (p.owner === 'enemy') { if (Math.abs(hero.x - p.x) < p.radius && hero.y > CONFIG.groundY - 150) window.__game.receiveHit(p.damage); } else if (Math.abs(dummy.x - p.x) < p.radius && dummy.y > CONFIG.groundY - 150) hitDummy(p.damage, p.knockback, p.color, p.x, p.y, false, true); } // SOLAN Sunmane Roar: three roars 0.38 s apart from wherever SOLAN stands; each sends a tall sound wave both ways // along the floor (ground projectiles with `tall`, so a jump does not clear them), 16 each, 48 total, fading after // SO.roar.range px. SOLAN is free after the 0.5 s cast; a rival farther away than the range is safe. function startSunroar(actor = hero) { const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, roars: 0, phase: 'cutin', kind: actor.bwRage ? (CF[actor === hero ? selectedCharacter : opponentCharacter] ? 'mode' : 'tornado') : 'forest', cf: CF[actor === hero ? selectedCharacter : opponentCharacter] ? (actor === hero ? selectedCharacter : opponentCharacter) : null }; if (actor === hero) sunroar = group; else enemySunroar = group; cinematic = .9; announceTimer = 0; { const vid = actor === hero ? selectedCharacter : opponentCharacter; startUltimateVoice(CF[vid] || vid === 'browo' ? vid : 'solan', actor); } } function updateSunroar(dt, s) { if (!s) return; if (s.cf) { updateCfUltimate(dt, s); return; } if ((s.owner === 'enemy' ? opponentCharacter : selectedCharacter) === 'browo') { (s.kind === 'tornado' ? updateTornado : updateForest)(dt, s); return; } const U = SO.roar, a = s.owner === 'enemy' ? dummy : hero; s.t += dt; while (s.roars < U.roars.length && s.t >= U.roars[s.roars]) { const last = s.roars === U.roars.length - 1; s.roars++; effects.push({ type: 'solan-fx', asset: 'solan-sunburst', x: a.x, y: a.y - 110, facing: s.facing, life: .3, maxLife: .3, size: 160 }); spawnParticles(a.x, a.y - 95, '#ffd36a', 16, 260); trauma = Math.min(1, trauma + .25); sound('heavy'); for (const dir of [-1, 1]) projectiles.push({ x: a.x + dir * 40, y: CONFIG.groundY - 110, vx: dir * U.speed, life: U.range / U.speed, facing: dir, owner: s.owner, damage: SO.balance.roarDamage, knockback: last ? 190 : 50, asset: 'solan-roar', size: U.size, color: '#ffd36a', tall: U.tall, freeze: false }); } s.phase = s.t < U.cutinDuration ? 'cutin' : s.roars < U.roars.length ? 'roaring' : 'fading'; if (s.t >= U.duration && !projectiles.some(p => p.asset === 'solan-roar' && p.owner === s.owner)) { if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } } // ======== Data-driven fighters (window.FIGHTERS) ====================================================================== // One table entry per character (assets//manifest.js). Skills are picked from a small library by `kind`: // projectile | wave | homing | dash | area | pillar | lob (skill1 / skill2 / basic chain is melee) // and the ultimate by `ultimate.kind`: volley | stampede | rain | pillars. All ultimates share the cinematic: the camera // dives onto the caster under a dark screen, the rival is held, the beats land, then an optional finisher. See work/WORKFLOW.md. const cfx = { ents: [], hazards: [], marks: [], later: [], dots: [], ghosts: [] }; function cfKit(d) { const move = (name, index = 1, mode = false) => { if (name === 'attack') { const c = d.combo[index - 1]; return { name: `attack${index}`, type: 'attack', index, ...c, reach: Math.round((c.reach || 105) * (CONFIG.reachScale || 1)) }; } if (name === 'ultimate') return { name: 'ultimate', type: 'ultimate', duration: d.balance.castTime, damage: 0, hitAt: 1 }; const k = (mode && d.mode?.skills?.[name]) || d.skills[name], m = { name, type: name, knockback: 160, duration: .6, ...k, hitAt: k.hitAt ?? .5 }; if (k.kind === 'projectile' || k.kind === 'wave' || k.kind === 'homing' || k.kind === 'lob') { m.projectile = true; m.range = k.range || 560; m.reach = m.range; } if (k.kind === 'dash') m.dash = k.dash; if (k.kind === 'area' || k.kind === 'pillar') { m.area = true; m.reach = k.reach || ((k.ahead || 0) + (k.radius || 0)); } return m; }; const start = (a, name, index = 1) => { if (a.action || a.state === 'hurt' || a.state === 'down' || a.state === 'recover') return false; if (name !== 'attack' && a.cooldowns[name] > 0) return false; const rg = !!a.bwRage && !!d.mode && name !== 'attack'; const alt = a === hero && !rg && (name === 'skill1' || name === 'skill2') ? window.Meta?.altSkill?.(d.id, name) : null; if (name !== 'attack') a.cooldowns[name] = rg ? (name === 'ultimate' ? 999 : (d.mode.cooldowns?.[name] ?? 6)) : d.balance.cooldowns[name]; a.action = { ...move(name, index, rg), t: 0, fired: false, queued: 0 }; if (alt) { const m = a.action, proj = ['projectile', 'wave', 'homing'].includes(m.kind); if (alt === 'ganda') { if (proj) { m.count = (m.count || 1) * 2; m.spread = m.spread || 55; m.damage = Math.max(1, Math.round(m.damage * .65)); a.cooldowns[name] *= 1.1; } else { m.damage = Math.round(m.damage * .8); m.duration *= .75; a.cooldowns[name] *= .7; } } if (alt === 'berat') { m.damage = Math.round(m.damage * 1.6); m.knockback = (m.knockback || 160) * 1.3; m.duration += .14; if (m.size) m.size = Math.round(m.size * 1.35); if (m.radius) m.radius = Math.round(m.radius * 1.35); if (m.speed) m.speed = Math.round(m.speed * .85); if (m.dash) m.dash *= 1.3; if (m.reach) m.reach = Math.round(m.reach * 1.15); a.cooldowns[name] *= 1.5; } } a.state = a.action.name; a.stateTime = 0; return true; }; const refund = a => { for (const n of Object.keys(d.balance.cooldowns)) a.cooldowns[n] = Math.max(0, a.cooldowns[n] - d.balance.cooldowns[n] * (d.balance.basicRefund ?? .05)); }; return { balance: d.balance, names: d.names, combo: d.combo, move, start, refund, def: d }; } function cfStrike(actor, def, move) { const enemy = actor === dummy, target = enemy ? hero : dummy, id = def.id, em = def.metrics?.emitters?.[move.name], col = def.color || '#ffd36a'; const hy = clamp(actor.y + (em?.y ?? -95), actor.y - 190, actor.y - 40), owner = enemy ? 'enemy' : 'player', key = a => id + '-' + a; const hit = (dmg, kb, x, y, knockdown = false) => enemy ? window.__game.receiveHit(dmg) : hitDummy(dmg, kb, col, x, y, knockdown, true); const raging = !!actor.bwRage && !!def.mode, mul = raging ? (def.mode.damageMul || 1.3) : 1, radd = raging ? (def.mode.reachAdd || 15) : 0; move = { ...move, damage: Math.round((move.damage || 0) * mul), reach: (move.reach || 0) + radd }; const k = move.kind; if (k === 'projectile' || k === 'wave' || k === 'homing') { const n = move.count || 1, ox = actor.x + actor.facing * Math.round((em?.x ?? 100) * .7), cv = move.convoy, grp = { hits: 0 }; for (let c = 0; c < (cv?.n || 1); c++) for (let i = 0; i < n; i++) { const off = (i - (n - 1) / 2) * (move.spread || 0), p = { x: ox, y: actor.y + (move.y ?? -95) + (k === 'homing' ? 0 : off) + (cv ? (c - 1) * 12 : 0), vx: actor.facing * move.speed, life: move.life || (move.range || 560) / move.speed, facing: actor.facing, owner, damage: cv ? Math.max(1, Math.round(move.damage * (cv.mul || .36))) : move.damage, knockback: move.knockback, asset: key(move.asset), size: move.size || 150, color: col, tall: k === 'wave' ? (move.tall || 170) : move.tall, pass: !!move.pierce }; if (k === 'homing') { p.vy = off - 60; p.vx = actor.facing * move.speed * .5; p.homing = true; p.speedH = move.speed; p.turn = move.turn || 5; p.tall = move.tall || 90; } fxProj(p, move); if (cv && c) p.wait = c * (cv.gap || .14); // convoy: setiap mobil menunggu giliran di titik awal if (move.swarm) { // semut mengitari penyihir dulu (arah putar acak per semut), lalu dilepas satu per satu mengejar lawan const S = move.swarm, release = (S.orbit || .55) + i * (S.stagger || .05); p.chase = move.life || 2.4; p.sw = { ang: i / n * 6.2832, w: (i % 2 ? 1 : -1) * (8 + (i % 3) * 1.5), R: (S.radius || 90) + (i % 3) * 16, release, go: false, speed: move.speed, turn: move.turn || 5, chase: p.chase, hd: 0 }; p.homing = false; p.vx = p.vy = 0; p.life = release + p.chase + .2; p.life0 = p.life; if (move.ramp) { p.ramp = { base: move.damage, every: move.ramp.every, add: move.ramp.add }; p.grp = grp; } } projectiles.push(p); } spawnParticles(ox, actor.y + (move.y ?? -95), col, 10, 160); sound('cast'); return; } if (k === 'lob') { const T = .85, vx = clamp((target.x - actor.x) / T, -720, 720); projectiles.push({ x: actor.x + actor.facing * 30, y: actor.y - 110, vx, vy: -(move.rise || 700), cfG: move.gravity || 1800, cfLand: { asset: move.puddle, radius: move.radius || 110, dps: move.dps || 6, life: move.puddleLife || 3.5, burst: move.damage }, spin: 9, life: 3, facing: vx >= 0 ? 1 : -1, owner, damage: Math.round(move.damage * .5), knockback: move.knockback, asset: key(move.asset), size: move.size || 90, color: col, tall: 80, pass: true }); sound('cast'); return; } let hitX = null, inside; if (k === 'dash') { effects.push({ type: 'cf-fx', asset: key(move.asset || 'impact'), x: actor.x + actor.facing * 60, y: actor.y - 80, facing: actor.facing, life: .3, maxLife: .3, size: move.size || 200 }); const dx = (target.x - actor.x) * actor.facing; inside = dx > -22 && dx < (move.reach || 120) + 27 && Math.abs(target.y - actor.y) <= 115; hitX = actor.x; // rebound: banteng api menembus lalu memantul kembali (proyektil pulang-pergi, satu pukulan tiap arah) // (spent: lawan yang sudah kena dash tidak dipukul lagi di jalan keluar, hanya saat banteng memantul kembali) if (move.rebound) { const R = move.rebound, rg = Math.max(150, (target.x - actor.x) * actor.facing - 50 + R.range), rp = { x: actor.x + actor.facing * 50, y: actor.y - 95, vx: actor.facing * (R.speed || 1000), life: 2 * rg / (R.speed || 1000) + .8, facing: actor.facing, owner, damage: Math.round(R.damage * mul), knockback: (move.knockback || 160) * .7, asset: key(R.asset || move.asset), size: R.size || 280, color: col, tall: 100, pass: true, spent: inside, reb: { range: rg, back: false } }; fxProj(rp, { fx: 'bull', kind: 'rebound' }); projectiles.push(rp); } } else if (k === 'area' || k === 'pillar') { hitX = actor.x + actor.facing * (move.ahead || 0); if (k === 'pillar') effects.push({ type: 'cf-pillar', img: 'fx-' + key(move.asset), x: hitX, y: CONFIG.groundY, life: .9, maxLife: .9, h: move.h || 330 }); else effects.push({ type: 'cf-fx', asset: key(move.asset || 'impact'), x: hitX, y: actor.y - 90, facing: 1, life: .5, maxLife: .5, size: move.size || (move.radius || 120) * 2.4 }); effects.push({ type: 'cf-fx', asset: key('impact'), x: hitX, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); dust(hitX, 14); spawnParticles(hitX, CONFIG.groundY - 10, col, 14, 280, 'dust'); trauma = Math.min(1, trauma + .25); if (k === 'area') fxAreaBlast(hitX, move.radius || 100, col, false); inside = Math.abs(target.x - hitX) < (move.radius || 100) && Math.abs(target.y - actor.y) <= (move.vy || 150); // aftershock: ledakan kedua beberapa saat kemudian di titik yang sama, radius lebih besar (menjangkau yang terlempar/menjauh) if (move.aftershock) { const A = move.aftershock; cfx.later.push({ t: A.delay || .22, fn: () => { fxAreaBlast(hitX, A.radius, col, true); trauma = Math.min(1, trauma + .2); sound('heavy'); if (bwAlive(target) && Math.abs(target.x - hitX) < A.radius && target.y > CONFIG.groundY - 140) hit(Math.round(A.damage * mul), move.knockback, hitX, hy, false); } }); } } else { // basic chain: a plain melee hit in front effects.push({ type: 'slash', x: actor.x + actor.facing * 55, y: hy + 5, life: .24, maxLife: .24, facing: actor.facing, index: move.index, color: col }); if (move.index === 3) { dust(actor.x + actor.facing * (em?.x ?? 95), 12); trauma = Math.min(1, trauma + .15); } const dx = (target.x - actor.x) * actor.facing; inside = dx > -22 && dx < move.reach + 27 && Math.abs(target.y - actor.y) <= 115; } if (!inside) { sound(k && k !== 'dash' ? 'heavy' : 'step'); return; } const connected = hit(move.damage, move.knockback, hitX ?? actor.x, hy, !!move.knockdown); if (connected && move.type === 'attack') { if (enemy) KITS[id].refund(dummy); else for (const name of Object.keys(cooldownMax)) { if (hero.cooldowns[name] > 0) rechargePulse[name] = .22; hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - cooldownMax[name] * BALANCE.basicCooldownRefund); } } if (connected) sound(k || move.index === 3 ? 'heavy' : 'hit'); } // Lob landing: a burst on the spot and a puddle that burns anyone standing in it. function cfLand(p) { const L = p.cfLand, id = p.asset.split('-')[0], target = p.owner === 'enemy' ? hero : dummy; cfx.hazards.push({ x: p.x, owner: p.owner, t: 0, life: L.life, radius: L.radius, dps: L.dps, img: 'fx-' + id + '-' + L.asset, acc: 0, shown: 0 }); effects.push({ type: 'cf-fx', asset: id + '-impact', x: p.x, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); spawnParticles(p.x, CONFIG.groundY - 12, p.color, 14, 260, 'dust'); trauma = Math.min(1, trauma + .2); sound('heavy'); if (bwAlive(target) && Math.abs(target.x - p.x) < L.radius && target.y > CONFIG.groundY - 120) dotDamage(target, L.burst, p.color, true, true); } function updateCf(dt) { fxTick(dt); // Entities (ultimate volleys and stampedes): move, then land one hit each on the rival they cross. for (let n = cfx.ents.length - 1; n >= 0; n--) { const e = cfx.ents[n], target = e.owner === 'enemy' ? hero : dummy; e.t += dt; e.life -= dt; if (e.homing && bwAlive(target)) { const want = Math.atan2(target.y - 90 - e.y, target.x - e.x), cur = Math.atan2(e.vy, e.vx); let d = want - cur; d = Math.atan2(Math.sin(d), Math.cos(d)); const ang = cur + clamp(d, -6 * dt, 6 * dt), sp = Math.hypot(e.vx, e.vy); e.vx = Math.cos(ang) * sp; e.vy = Math.sin(ang) * sp; } e.x += e.vx * dt; e.y += e.vy * dt; if (!e.hit && bwAlive(target) && Math.abs(e.x - target.x) < (e.rx || 70) && Math.abs(e.y - (target.y - 85)) < (e.ry || 120)) { dotDamage(target, e.damage, e.color, true, true); sound('hit'); spawnParticles(target.x, target.y - 85, e.color, 8, 220); trauma = Math.min(1, trauma + .15); if (e.single) { cfx.ents.splice(n, 1); continue; } e.hit = true; } if (e.life <= 0 || e.x < -260 || e.x > W + 260) cfx.ents.splice(n, 1); } // Marked strikes (rain): after a telegraph, damage whoever stands on the mark. for (let n = cfx.marks.length - 1; n >= 0; n--) { const m = cfx.marks[n], target = m.owner === 'enemy' ? hero : dummy; m.t += dt; if (m.t >= m.delay) { effects.push({ type: 'cf-fx', asset: m.asset, x: m.x, y: CONFIG.groundY - 26, facing: 1, life: .4, maxLife: .4, size: 200 }); spawnParticles(m.x, CONFIG.groundY - 10, m.color, 8, 200, 'dust'); sound('hit'); trauma = Math.min(1, trauma + .12); if (bwAlive(target) && Math.abs(target.x - m.x) < m.radius && target.y > CONFIG.groundY - 140) dotDamage(target, m.damage, m.color, true, true); cfx.marks.splice(n, 1); } } // Puddles: one burn per rival, however many puddles they stand in. const inside = { player: false, enemy: false }, hz = {}, hcol = {}; for (let n = cfx.hazards.length - 1; n >= 0; n--) { const h = cfx.hazards[n], target = h.owner === 'enemy' ? hero : dummy, tk = h.owner === 'enemy' ? 'player' : 'enemy'; h.t += dt; if (bwAlive(target) && Math.abs(target.x - h.x) < h.radius && target.y > CONFIG.groundY - 110) { inside[tk] = true; hz[tk] = Math.max(hz[tk] || 0, h.dps); hcol[tk] = h.color || '#9bd05a'; } if (h.t > h.life) cfx.hazards.splice(n, 1); } for (const k of ['player', 'enemy']) { if (!inside[k]) continue; const target = k === 'player' ? hero : dummy; bwx.burn[k] += hz[k] * dt; if (bwx.burn[k] >= 1) { const m = Math.floor(bwx.burn[k]); bwx.burn[k] -= m; bwx.burn.shown[k] += m; dotDamage(target, m, hcol[k], false); } if (bwx.burn.shown[k] >= 3) { damageNumbers.push({ x: target.x + (random() - .5) * 24, y: target.y - 130, amount: bwx.burn.shown[k], life: .9, maxLife: .9, color: hcol[k] }); bwx.burn.shown[k] = 0; } } } // The ultimate director. s.t counts from the cast; beats start after `hold` seconds of camera dive. function updateCfUltimate(dt, s) { const def = CF[s.cf], U = s.kind === 'mode' ? def.mode.ultimate : def.ultimate, a = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, tk = s.owner === 'enemy' ? 'player' : 'enemy'; const hold = U.hold ?? 1.1, gap = U.gap ?? .12, count = U.count ?? 10, endAct = hold + (count - 1) * gap + (U.settle ?? .5), col = def.color || '#ffd36a', id = def.id; s.t += dt; const mx = (a.x + target.x) / 2, dist = Math.abs(target.x - a.x); if (s.t < hold) { bwx.zoomT = U.zoom ?? 1.7; bwx.dimT = U.dim ?? .8; bwx.cx = a.x; bwx.cy = a.y - 90; if (s.t > .15) trauma = Math.min(.35, trauma + dt * .5); } else if (s.t < endAct + .3) { bwx.zoomT = 1.25; bwx.dimT = .65; bwx.cx = mx; bwx.cy = a.y - 100; } else { bwx.zoomT = 1; bwx.dimT = 0; } if (s.t >= (U.lockAt ?? .3) && s.t < endAct && bwAlive(target)) bwLock(tk, .25); while (s.roars < count && s.t >= hold + s.roars * gap) { const i = s.roars++, f = a.facing, owner = s.owner; if (U.kind === 'volley') { const sp = U.speed || 900, ang = Math.atan2((target.y - 90) - (a.y - 95) + (random() - .5) * 90, Math.max(60, Math.abs(target.x - a.x))) ; cfx.ents.push({ x: a.x + f * 50, y: a.y - 95 + (i % 3 - 1) * 24, vx: f * Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1.4, t: 0, owner, damage: U.damage ?? 4, color: col, asset: id + '-' + U.asset, size: U.size || 100, single: true, homing: !!U.homing, facing: f }); sound('cast'); } else if (U.kind === 'stampede') { const fromLeft = f > 0, sp = U.speed || 1000, lane = ((i % 3) - 1) * 12; cfx.ents.push({ x: fromLeft ? -120 : W + 120, y: CONFIG.groundY - (U.lift ?? 90) + lane, vx: (fromLeft ? 1 : -1) * sp * (.9 + (i % 4) * .06), vy: 0, life: (W + 340) / sp + .3, t: 0, owner, damage: U.damage ?? 8, color: col, asset: id + '-' + U.asset, size: U.size || 260, facing: fromLeft ? 1 : -1, rx: U.rx || 90, ry: U.ry || 130 }); trauma = Math.min(1, trauma + .12); sound('heavy'); } else if (U.kind === 'rain') { const x = clamp(target.x + (i === count - 1 ? 0 : (random() - .5) * (U.spread ?? 360)), 70, W - 70); cfx.marks.push({ x, t: 0, delay: U.delay ?? .38, owner, damage: U.damage ?? 3, radius: U.radius ?? 100, asset: id + '-impact', color: col, img: 'fx-' + id + '-' + U.asset, drop: U.size || 150 }); } else if (U.kind === 'pillars') { const x = clamp(a.x + f * (130 + i * (Math.max(dist, 200) - 30) / Math.max(1, count - 1)), 70, W - 70); effects.push({ type: 'cf-pillar', img: 'fx-' + id + '-' + U.asset, x, y: CONFIG.groundY, life: .6, maxLife: .6, h: U.h || 320 }); effects.push({ type: 'cf-fx', asset: id + '-impact', x, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); spawnParticles(x, CONFIG.groundY - 10, col, 10, 240, 'dust'); trauma = Math.min(1, trauma + .18); sound('hit'); if (bwAlive(target)) dotDamage(target, U.damage ?? 3, col, true, true); } } if (!s.finished && s.t >= endAct && U.finisher) { s.finished = true; const F2 = U.finisher; effects.push({ type: 'cf-fx', asset: id + '-impact', x: target.x, y: CONFIG.groundY - 40, facing: 1, life: .6, maxLife: .6, size: 520 }); effects.push({ type: 'cf-fx', asset: id + '-' + (F2.asset || 'burst'), x: target.x, y: CONFIG.groundY - 130, facing: 1, life: .55, maxLife: .55, size: 560 }); for (let q = 0; q < 3; q++) spawnParticles(target.x, CONFIG.groundY - 12, q === 1 ? '#fff1c8' : col, 22, 460, 'dust'); trauma = 1; hitstop = Math.max(hitstop, .14); sound('heavy'); if (bwAlive(target)) { if (target === dummy) { if (!hitDummy(F2.damage, 360, col, a.x, CONFIG.groundY - 100, true, true, 2)) dotDamage(dummy, F2.damage, col, true, true); } else if (!window.__game.receiveHit(F2.damage, { shake: 2 })) dotDamage(hero, F2.damage, col); } } s.phase = s.t < .78 ? 'cutin' : 'roaring'; if (s.t >= endAct + (U.tail ?? .5)) { const ok = s.owner === 'enemy' ? 'enemy' : 'player'; if (s.kind === 'mode') bwExitRage(ok); else if (def.mode) { bwEnterRage(ok); announce(def.mode.announce || 'MODE!', 1.3); effects.push({ type: 'cf-fx', asset: s.cf + '-burst', x: a.x, y: a.y - 95, facing: 1, life: .6, maxLife: .6, size: 380 }); } if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } } function cfCutin(key, owner) { const el = document.querySelector('#ultimate-cutin'); if (!el) return; const isMode = key.endsWith('~mode'), f = CF[key.replace('~mode', '')]; if (!f) { el.style.background = ''; el.style.borderColor = ''; el.querySelector('.cutin-kicker').style.color = ''; const e0 = el.querySelector('.cutin-caption em'); if (e0) e0.style.color = ''; return; } const c = (isMode ? f.mode?.cutin : f.cutin) || {}, nm = isMode ? f.mode.names[3] : f.names[3]; el.style.background = `linear-gradient(100deg,${c.a || '#222'},${c.b || '#555'} 58%,${c.a || '#222'})`; el.style.borderColor = (isMode && f.mode.aura) || f.color || '#fff'; el.querySelector('.cutin-kicker').textContent = f.name + ' / ' + owner; el.querySelector('.cutin-kicker').style.color = c.kicker || '#fff'; el.querySelector('strong').innerHTML = c.title || nm; el.querySelector('.cutin-detail').textContent = c.detail || ''; el.setAttribute('aria-label', f.name + ' ultimate: ' + nm); const em = el.querySelector('.cutin-caption em'); if (em) em.style.color = f.color || ''; } function drawCfBack() { for (const e of effects) { if (e.type !== 'cf-pillar') continue; const im = images[e.img]; if (!im) continue; const t = 1 - e.life / e.maxLife, k = Math.min(1, t * 7) * (t > .7 ? 1 - (t - .7) / .3 : 1), h = e.h, w = im.width / im.height * h; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.beginPath(); ctx.rect(-w, -h - 10, w * 2, h + 10); ctx.clip(); ctx.drawImage(im, -w / 2, -h * k, w, h); ctx.restore(); } for (const e of effects) { // retakan tanah dari jurus area if (e.type !== 'cf-crack') continue; const t = 1 - e.life / e.maxLife, a = Math.min(1, t * 12) * (t > .6 ? (1 - t) / .4 : 1); ctx.save(); ctx.translate(Math.round(e.x), e.y); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (const [col, wd, al, oy] of [['#2a1a10', 3.2, .85, 0], ['#f2e2c0', 1.2, .4, -1.5]]) { ctx.strokeStyle = col; ctx.lineWidth = wd; ctx.globalAlpha = a * al; ctx.beginPath(); for (const pts of e.segs) { ctx.moveTo(pts[0][0], pts[0][1] + oy); for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j][0], pts[j][1] + oy); } ctx.stroke(); } ctx.restore(); } fxDrawTele(hero, CF[selectedCharacter]?.color || '#ffd36a'); fxDrawTele(dummy, CF[opponentCharacter]?.color || '#ffd36a'); fxDrawGhosts(); fxDrawDash(hero, CF[selectedCharacter]); fxDrawDash(dummy, CF[opponentCharacter]); for (const h of cfx.hazards) { if (h.fire) { fxDrawFire(h); continue; } const im = images[h.img]; if (!im) continue; const fade = h.t > h.life - .6 ? Math.max(0, (h.life - h.t) / .6) : 1, w = h.radius * 2.4, hh = im.height / im.width * w; ctx.save(); ctx.globalAlpha = .9 * fade; ctx.drawImage(im, Math.round(h.x - w / 2), Math.round(CONFIG.groundY + 4 - hh / 2), w, hh); ctx.restore(); // genangan terlihat di tengah gambar persegi: pusatnya diletakkan di lantai } } function drawCfFront() { for (const m of cfx.marks) { // rain: telegraph ring, then the drop falls in const im = images[m.img], p = clamp(m.t / m.delay, 0, 1); ctx.save(); ctx.globalAlpha = .45; ctx.strokeStyle = m.color; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(m.x, CONFIG.groundY + 4, m.radius * p, m.radius * p * .22, 0, 0, 7); ctx.stroke(); ctx.restore(); if (im) { const sz = m.drop; ctx.drawImage(im, Math.round(m.x - sz / 2), Math.round(-sz + (CONFIG.groundY + sz * .3) * p * p), sz, sz); } } for (const e of cfx.ents) { const im = images['fx-' + e.asset]; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.scale(e.facing || 1, 1); if (e.vy && e.single) ctx.rotate(Math.atan2(e.vy, Math.abs(e.vx))); if (im) { const sz = e.size; ctx.drawImage(im, -sz / 2, -sz / 2, sz, sz); } else { ctx.fillStyle = e.color; ctx.beginPath(); ctx.arc(0, 0, 14, 0, 7); ctx.fill(); } ctx.restore(); } } // ======== Efek jurus (FX): mesin efek bersama untuk jurus berbasis data ============================================== // Semua canvas 2D, tanpa gambar baru. Proyektil/gelombang punya umur (p.age): muncul membesar dengan overshoot, memanjang // sesuai kecepatan, irisan vertikal bergoyang, ekor bayangan + percikan, memudar di akhir hidup, pencampuran cahaya per gaya // (p.fx = rings | shout | chain | roll | spray | drive | wave | flame | bolt | ant). Jurus area: cincin aba-aba, gelombang // kejut, retakan. Dash: bayangan sprite, debu, garis kecepatan. Jurus mode: flag data di manifest (lihat cfStrike). // Pengacak kosmetik terpisah dari random() agar hasil pertandingan tidak berubah oleh efek. let fxSeed = 90210; function fxr() { fxSeed = (Math.imul(fxSeed, 1103515245) + 12345) >>> 0; return fxSeed / 4294967296; } const easeOutBack = t => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2); const FX_GLYPHS = ['A', 'H', '!', '?', 'W', 'O', '#', 'E']; function fxPart(x, y, vx, vy, life, size, color, kind, extra) { if (particles.length > 460) return; particles.push({ x, y, vx, vy, life, maxLife: life, size, color, kind, fade: true, rot0: fxr() * 6.28, spin: (fxr() - .5) * 9, ...extra }); } function fxDrawPart(p) { const k = clamp(p.life / p.maxLife, 0, 1), x = Math.round(p.x), y = Math.round(p.y); if (p.kind === 'foam') { ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(x, y, p.size * (.45 + .55 * k), 0, 7); ctx.fill(); } else if (p.kind === 'bubble') { ctx.strokeStyle = p.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, p.size * (1.2 - .4 * k), 0, 7); ctx.stroke(); } else if (p.kind === 'glyph') { ctx.font = `900 ${p.size}px Rajdhani, sans-serif`; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = p.edge || '#10243c'; ctx.translate(x, y); ctx.rotate(p.rot0 * .2 + (1 - k) * p.spin * .4); ctx.strokeText(p.ch, 0, 0); ctx.fillStyle = p.color; ctx.fillText(p.ch, 0, 0); } else if (p.kind === 'streak') { const m = Math.hypot(p.vx, p.vy) || 1, L = p.len * (.4 + .6 * k); ctx.strokeStyle = p.color; ctx.lineWidth = p.size; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - p.vx / m * L, y - p.vy / m * L); ctx.stroke(); } else if (p.kind === 'ember') { ctx.globalCompositeOperation = 'lighter'; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(x, y, p.size * k * .6 + .8, 0, 7); ctx.fill(); } else if (p.kind === 'chip') { ctx.translate(x, y); ctx.rotate(p.rot0 + (1 - k) * p.spin * 2); ctx.fillStyle = p.color; ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * .66); } else ctx.fillRect(x, y, Math.max(1, Math.round(p.size)), Math.max(1, Math.round(p.size))); } // Projectile set-up, called by cfStrike for every projectile it creates: gives it a life clock, a trail and its flags. const FX_FLAGS = ['lift', 'dot', 'bind', 'trail', 'split', 'fire']; function fxProj(p, move) { p.fx = move.fx || (move.kind === 'wave' ? 'wave' : move.kind === 'homing' ? 'ant' : 'bolt'); p.age = 0; p.life0 = p.life; p.tr = []; p.acc = 0; p.sx0 = p.x; for (const f of FX_FLAGS) if (move[f]) p[f] = move[f]; } // One simulation step of a FX projectile's cosmetics and special behaviour. Returns 'remove' / 'skip' (no collision this step). function fxProjTick(p, dt) { p.age += dt; p.acc += dt; const f = p.facing || 1, gy = CONFIG.groundY; if (p.age - (p.trT || 0) >= .045) { p.trT = p.age; p.tr.push({ x: p.x, y: p.y }); if (p.tr.length > 4) p.tr.shift(); } const mr = fxMech(p, dt); if (mr) return mr; if (p.acc >= .03) { p.acc -= .03; const sz = p.size || 150; if (p.fx === 'shout') { fxPart(p.x - f * 18, p.y + (fxr() - .5) * 46, -f * (90 + fxr() * 200), (fxr() - .5) * 260, .42 + fxr() * .25, 15 + fxr() * 14, fxr() < .5 ? '#d8f1ff' : '#7fc4ff', 'glyph', { ch: FX_GLYPHS[Math.floor(fxr() * FX_GLYPHS.length)], g: 260, drag: 1.5 }); fxPart(p.x - f * 8, p.y + (fxr() - .5) * 40, -f * (60 + fxr() * 160), (fxr() - .5) * 200, .3, 3 + fxr() * 3, '#bfe6ff', 'ember', {}); } else if (p.fx === 'rings') { const a = (fxr() - .5) * 1.9; fxPart(p.x + f * sz * .22 * Math.cos(a), p.y + sz * .3 * Math.sin(a), f * (30 + fxr() * 90), (fxr() - .5) * 120, .35, 4 + fxr() * 3, '#ffd9a0', 'ember', { g: 0, drag: 2 }); } else if (p.fx === 'chain') { const k = Math.floor(fxr() * 5); fxPart(p.x - f * k * sz * .17, p.y + (fxr() - .5) * 80, -f * (20 + fxr() * 60), -40 - fxr() * 90, .55, 14 + fxr() * 8, '#ffe9a6', 'glyph', { ch: fxr() < .5 ? '♪' : '♫', g: -20, drag: 1.5, edge: '#5a3a08' }); } else if (p.fx === 'roll') { const grow = fxGrow(p), top = p.y + 95 - sz * grow * .55; fxPart(p.x + f * (fxr() * sz * .35 - sz * .1), top + fxr() * sz * .25 * grow, f * (60 + fxr() * 140), -170 - fxr() * 170, .5 + fxr() * .25, 3 + fxr() * 4, p.fire ? '#ffb347' : '#e8fff6', p.fire ? 'ember' : 'foam', { g: 760, drag: 1, alpha: .85 }); if (fxr() < .5) fxPart(p.x - f * sz * .3 * fxr(), gy - 4, -f * (30 + fxr() * 50), -20 - fxr() * 40, .45, 7 + fxr() * 5, '#d8fff0', 'foam', { g: 120, drag: 2 }); } else if (p.fx === 'spray') { fxPart(p.x + f * (fxr() - .3) * sz * .4, p.y + (fxr() - .5) * 60, f * (40 + fxr() * 140), (fxr() - .2) * 240, .5 + fxr() * .3, 4 + fxr() * 5, fxr() < .5 ? '#a6f04a' : '#d8ff7a', 'foam', { g: 700, drag: 1.2 }); } else if (p.fx === 'flame' || p.fx === 'bull') { fxPart(p.x - f * fxr() * sz * .35, p.y + (fxr() - .5) * 70, -f * (20 + fxr() * 60), -60 - fxr() * 130, .5, 5 + fxr() * 6, fxr() < .5 ? '#ff9a3a' : '#ffd36a', 'ember', { g: -80, drag: 1.5 }); } else if (p.fx === 'drive') { fxPart(p.x - f * sz * .38, gy - 6 - fxr() * 10, -f * (30 + fxr() * 80), -20 - fxr() * 60, .5, 8 + fxr() * 8, '#e7d2a8', 'foam', { g: -30, drag: 2, alpha: .4 }); } else if (p.fx === 'wave') { fxPart(p.x - f * fxr() * sz * .3, p.y + (fxr() - .5) * 60, -f * (30 + fxr() * 80), (fxr() - .5) * 120, .4, 3 + fxr() * 3, p.color || '#fff', 'ember', { g: 0 }); } else if (p.fx === 'bolt') { fxPart(p.x - f * 20, p.y + (fxr() - .5) * 36, -f * (30 + fxr() * 100), (fxr() - .5) * 140, .38, 3 + fxr() * 3, p.color || '#fff', 'ember', { g: 0 }); } } return null; } function fxGrow(p) { return .55 + .85 * clamp(p.age / ((p.life0 || 1) * .75), 0, 1); } function fxStrips(im, w, h, n, amp, freq, phase, hk, dyk) { const sw = im.width / n, dw = w / n; for (let i = 0; i < n; i++) { const u = (i + .5) / n, hh = h * (hk ? hk(u) : 1), dy = Math.sin(phase + u * freq * 6.2832) * amp + (dyk ? dyk(u) : 0); ctx.drawImage(im, i * sw, 0, sw, im.height, -w / 2 + i * dw, -hh / 2 + dy, dw + .45, hh); } } function drawFxProjectile(p) { const im = images['fx-' + p.asset]; if (!im || p.wait > 0) return; const age = p.age, size = p.size || 150, f = p.facing || 1, sp = Math.abs(p.vx || 0), gy = CONFIG.groundY; const pop = easeOutBack(clamp(age / .18, 0, 1)), out = clamp(p.life / .25, 0, 1), stretch = 1 + Math.min(.2, sp / 4200); ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.scale(f, 1); ctx.imageSmoothingEnabled = true; const ghosts = (w, h, a0, dyo = 0) => { const n = p.tr.length; for (let j = 0; j < n; j++) { const t = p.tr[j], u = (j + 1) / (n + 1); ctx.globalAlpha = a0 * u * u * out; ctx.drawImage(im, (t.x - p.x) * f - w / 2, t.y - p.y - h / 2 + dyo, w, h); } ctx.globalAlpha = out; }; ctx.globalAlpha = out; if (p.fx === 'rings') { // Mega Chan: concentric arcs swell out of the wave front and pulse like a speaker; the red crescents ride in front. const R = size * .6, c0 = -size * .3; ctx.lineCap = 'round'; for (let r = 0; r < 4; r++) { const ph = (age * 1.6 + r / 4) % 1, rad = (.18 + ph * .95) * R * pop, pulse = 1 + .09 * Math.sin(age * 30 + r * 2), th = 1.0 - .3 * ph, al = Math.sin(Math.PI * Math.pow(ph, .5)) * out; for (const [wd, col, a2] of [[(20 * (1 - ph) + 7) * pulse, '#e0301c', .8], [(9 * (1 - ph) + 3) * pulse, '#ff8a4a', .9], [(3.5 * (1 - ph) + 1.5) * pulse, '#fff4d0', 1]]) { ctx.globalAlpha = al * a2; ctx.strokeStyle = col; ctx.lineWidth = wd; ctx.beginPath(); ctx.arc(c0, 0, rad * pulse, -th, th); ctx.stroke(); } } ctx.globalAlpha = out * .95; const w = size * .78 * pop, hh = size * .6 * pop * (1 + .06 * Math.sin(age * 28)); ctx.drawImage(im, -w * .4, -hh / 2, w, hh); } else if (p.fx === 'shout') { ghosts(size * 1.1, size * .8, .35); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .5; ctx.drawImage(im, -size * .55 * 1.6 * pop, -size * .34 * pop, size * 1.1 * 1.6 * pop, size * .68 * pop); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = out; ctx.drawImage(im, -size * .5 * 1.45 * pop, -size * .3 * pop, size * 1.45 * pop, size * .6 * pop); } else if (p.fx === 'chain') { // Gufron Syududu: a chain of pulsing rings that weaves low through the air, each link beating a little after the one ahead. const n = 6, sp0 = size * .19, amp = 30 * Math.min(1, age * 4); for (let k = n - 1; k >= 0; k--) { const beat = Math.max(0, Math.sin(age * 13 - k * 1.25)), sc = (1 - k * .085) * pop * (.82 + .3 * beat), wy = Math.sin(age * 8.5 - k * .85) * amp, tilt = Math.cos(age * 8.5 - k * .85) * .38; ctx.save(); ctx.translate(-k * sp0, wy); ctx.rotate(tilt); ctx.globalAlpha = out * (1 - k * .12); ctx.drawImage(im, -size * .46 * sc, -size * .46 * sc, size * .92 * sc, size * .92 * sc); if (beat > .5) { ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .5 * (beat - .5) * 2; ctx.drawImage(im, -size * .46 * sc, -size * .46 * sc, size * .92 * sc, size * .92 * sc); } ctx.restore(); } } else if (p.fx === 'roll') { const grow = fxGrow(p), w = size * 1.05 * stretch * pop, h = size * grow * pop, base = 101 - h * .195, roll = age * 11; ghosts(w * .96, h * .9, .22, base); ctx.translate(0, base); fxStrips(im, w, h, 26, h * .022, 1.7, -roll, u => 1 + .34 * (u - .5) + .05 * Math.sin(roll + u * 9), u => -h * .09 * Math.max(0, u - .55) * 1.6 + Math.sin(roll * .7 + u * 3) * h * .012); if (p.fire) { ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .35; fxStrips(im, w, h, 18, h * .022, 1.7, -roll, u => 1 + .34 * (u - .5)); } } else if (p.fx === 'spray') { const w = size * 1.1 * stretch * pop, h = size * .9 * pop; ghosts(w * .95, h * .85, .25); fxStrips(im, w, h, 32, h * .022, 2.2, -age * 20, u => .6 + .5 * u); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .22; ctx.drawImage(im, -w / 2, -h * .3, w, h * .6); } else if (p.fx === 'flame') { const w = size * 1.1 * stretch * pop, h = size * pop; ghosts(w * .94, h * .9, .3); fxStrips(im, w, h, 36, h * .02, 1.6, -age * 20, null); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .3; ctx.drawImage(im, -w / 2, -h / 2, w, h); } else if (p.fx === 'drive') { const w = size * stretch * pop, h = size * pop, bounce = Math.abs(Math.sin(age * 22)) * -4; ghosts(w * .98, h, .18, bounce); ctx.translate(0, gy - 6 - p.y - h * .15 + bounce + (p.y - (gy - 95)) * 0); ctx.drawImage(im, -w / 2, -h / 2, w, h); } else if (p.fx === 'wave') { const w = size * 1.12 * stretch * pop, h = size * pop; ghosts(w * .96, h * .92, .24); fxStrips(im, w, h, 22, h * .028, 1.6, -age * 13, null); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .28; ctx.drawImage(im, -w / 2, -h / 2, w, h); } else if (p.fx === 'bull') { // banteng pantul: satu gambar utuh, ekor bayangan dan bara const w = size * pop, h = size * pop; ghosts(w * .96, h * .96, .32); ctx.drawImage(im, -w / 2, -h / 2 + Math.sin(age * 40) * 2, w, h); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .22; ctx.drawImage(im, -w * .53, -h * .53, w * 1.06, h * 1.06); } else if (p.fx === 'ant') { // semut: menghadap arah gerak, kaki bergetar; mengitari penyihir lalu mengejar const ha = p.sw && !p.sw.go ? p.sw.hd : Math.atan2(p.vy || 0, p.vx || 1), left = Math.cos(ha) < 0, w = size * pop; ctx.scale(f, 1); ctx.rotate(left ? ha - Math.PI : ha); if (left) ctx.scale(-1, 1); ctx.drawImage(im, -w / 2, -w / 2 * (1 + .1 * Math.sin(age * 55 + (p.sw?.ang || 0))), w, w * (1 + .1 * Math.sin(age * 55 + (p.sw?.ang || 0)))); } else { // bolt: speech bubbles and other plain shots const w = size * stretch * pop, h = size * pop; ghosts(w * .95, h * .95, .28); ctx.drawImage(im, -w / 2, -h / 2 + Math.sin(age * 18) * 2, w, h); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = out * .25; ctx.drawImage(im, -w * .55, -h * .55, w * 1.1, h * 1.1); } ctx.restore(); } // ---- Mekanik jurus (flag data di manifest, dipakai pemain dan CPU lewat cfStrike) -------------------------------------- // rebound {range,damage,speed,asset} : dash menembus; banteng api terus melaju `range` px melewati lawan lalu memantul dan memukul lagi // trail {gap,radius,dps,life} : gelombang meninggalkan genangan api di lantai (dps selama berdiri di dalamnya) // split {at,dmg} : gelombang membelah jadi dua setelah menempuh `at` px (tiap belahan dmg x damage) // dot {dps,time} : korosi, damage berkala setelah kena (diperbarui bila kena lagi) // bind seconds : rantai membelit, lawan tertahan sejenak // convoy {n,mul,gap} : n proyektil beruntun, masing-masing mul x damage // aftershock {delay,radius,damage} : jurus area meledak lagi di titik yang sama dengan radius lebih besar // pull px/s : jurus area menyedot lawan ke pusat selama aba-aba // lift px/s : mengangkat lawan sedikit saat kena // swarm {orbit,radius} + ramp {every,add}: semut mengitari penyihir dulu lalu mengejar satu per satu; damage naik di tengah function fxTarget(owner) { return owner === 'enemy' ? hero : dummy; } function fxLift(t, v) { if (t === dummy) { if (dummy.state === 'down' || dummy.state === 'recover') return; dummy.vy = Math.min(dummy.vy, -v); dummy.y -= 2; } else if (hero.hp > 0) { hero.vy = Math.min(hero.vy, -v); hero.y -= 2; hero.grounded = false; } } function fxBind(owner, secs, col) { const t = fxTarget(owner); bwLock(owner === 'enemy' ? 'player' : 'enemy', secs); effects.push({ type: 'cf-wrap', who: owner === 'enemy' ? 'hero' : 'dummy', life: secs, maxLife: secs, color: col, x: t.x, y: t.y }); } function fxAddDot(owner, d, col) { const old = cfx.dots.find(o => o.owner === owner); if (old) { old.t = 0; old.dps = Math.max(old.dps, d.dps); return; } cfx.dots.push({ owner, t: 0, time: d.time || 2.5, dps: d.dps || 3, acc: 0, shown: 0, color: d.color || col }); } function fxFire(x, owner, o) { cfx.hazards.push({ x, owner, t: 0, life: o.life || 3, radius: o.radius || 58, dps: o.dps || 3, img: null, fire: true, color: '#ff9a4a', acc: 0, shown: 0, seed: fxr() }); } // Lidah api digambar sekali dalam ukuran satuan (lebar 1, tinggi 1) dengan gradasi yang dipakai ulang, lalu diskalakan. let fxFlameG = null, fxGlowG = null; function fxDrawFire(h) { if (!fxFlameG) { fxFlameG = ctx.createLinearGradient(0, 0, 0, -1); fxFlameG.addColorStop(0, '#ffe680'); fxFlameG.addColorStop(.35, '#ff9a2a'); fxFlameG.addColorStop(.8, 'rgba(232,72,28,.85)'); fxFlameG.addColorStop(1, 'rgba(232,72,28,0)'); fxGlowG = ctx.createRadialGradient(0, 0, 0, 0, 0, 1); fxGlowG.addColorStop(0, 'rgba(255,170,40,.95)'); fxGlowG.addColorStop(.6, 'rgba(255,90,20,.4)'); fxGlowG.addColorStop(1, 'rgba(255,60,10,0)'); } const grow = Math.min(1, h.t / .25), fade = h.t > h.life - .6 ? Math.max(0, (h.life - h.t) / .6) : 1, gy = CONFIG.groundY + 6, R = h.radius; ctx.save(); ctx.globalAlpha = fade * .55; ctx.translate(h.x, gy); ctx.save(); ctx.scale(R * 1.1, R * .3); ctx.fillStyle = fxGlowG; ctx.beginPath(); ctx.arc(0, 0, 1, 0, 7); ctx.fill(); ctx.restore(); ctx.globalAlpha = fade * .92; ctx.fillStyle = fxFlameG; for (let i = 0; i < 8; i++) { const u = i / 3.5 - 1, fh = (34 + 30 * (.5 + .5 * Math.sin(realTime * 9 + i * 1.9 + h.seed * 6))) * grow * (1 - Math.abs(u) * .4), sk = Math.sin(realTime * 7 + i * 2.3) * .45; ctx.save(); ctx.translate(u * R * .85, 0); ctx.scale(13, fh); ctx.beginPath(); ctx.moveTo(-1, 0); ctx.bezierCurveTo(-1.1, -.45, -.2, -.6, sk, -1); ctx.bezierCurveTo(.3, -.55, 1.1, -.4, 1, 0); ctx.closePath(); ctx.fill(); ctx.restore(); } ctx.restore(); } // Mekanik per proyektil; dipanggil dari fxProjTick sebelum tabrakan. 'skip' = tidak menabrak langkah ini, 'remove' = hapus. function fxMech(p, dt) { const home = p.owner === 'enemy' ? dummy : hero, tgt = fxTarget(p.owner); if (p.ramp) p.damage = p.ramp.base + (p.grp.hits >= p.ramp.every ? p.ramp.add : 0); if (p.sw && !p.sw.go) { const s = p.sw; s.ang += s.w * dt; const ex = s.R, ey = s.R * .62; p.x = home.x + Math.cos(s.ang) * ex; p.y = home.y - 100 + Math.sin(s.ang) * ey; const vx = -Math.sin(s.ang) * ex * s.w, vy = Math.cos(s.ang) * ey * s.w; s.hd = Math.atan2(vy, vx); p.facing = vx >= 0 ? 1 : -1; if (p.age < s.release) return 'skip'; s.go = true; const a = Math.atan2(tgt.y - 85 - p.y, tgt.x - p.x); p.homing = true; p.speedH = s.speed; p.turn = s.turn; p.vx = Math.cos(a) * s.speed * .6; p.vy = Math.sin(a) * s.speed * .6; p.life = s.chase; fxPart(p.x, p.y, 0, 0, .25, 5, '#3a2a1a', 'ember', {}); } if (p.trail && p.age > .08 && Math.abs(p.x - (p.lastDrop ?? p.sx0)) >= (p.trail.gap || 110)) { p.lastDrop = p.x; fxFire(p.x, p.owner, p.trail); } if (p.split && !p.splitDone && !p.spent && Math.abs(p.x - p.sx0) >= p.split.at) { p.splitDone = true; for (const s of [-1, 1]) projectiles.push({ ...p, tr: [], y: p.y + s * 14, vy: s * 90, damage: Math.max(1, Math.round(p.damage * (p.split.dmg || .55))), size: Math.round(p.size * .74), tall: 95, split: null, pass: true, spent: false, trT: 0 }); effects.push({ type: 'cf-ring', x: p.x, y: p.y, life: .3, maxLife: .3, color: p.color, r: 90 }); spawnParticles(p.x, p.y, p.color, 10, 220); return 'remove'; } if (p.reb) { const R = p.reb; if (!R.back && Math.abs(p.x - p.sx0) >= R.range) { R.back = true; p.spent = false; p.vx = -p.vx * 1.1; p.facing = p.vx >= 0 ? 1 : -1; effects.push({ type: 'cf-ring', x: p.x, y: p.y, life: .3, maxLife: .3, color: p.color, r: 80 }); spawnParticles(p.x, p.y, p.color, 12, 260); sound('hit'); } if (R.back && Math.abs(home.x - p.x) < 50) return 'remove'; } return null; } function fxOnHit(p) { const t = fxTarget(p.owner), f = p.facing || 1, x = t.x - f * 12, y = t.y - 90, col = p.color || '#fff'; const landed = t.state === 'hurt' || t.state === 'down'; // hit yang tertahan blok/parry tidak memicu efek susulan if (landed) { if (p.grp) p.grp.hits++; if (p.lift) fxLift(t, p.lift); if (p.dot) fxAddDot(p.owner, p.dot, col); if (p.bind) fxBind(p.owner, p.bind, col); } if (p.fx === 'roll') for (let i = 0; i < 16; i++) fxPart(x, t.y - 20 - fxr() * 40, (fxr() - .5) * 300, -260 - fxr() * 340, .6, 4 + fxr() * 6, p.fire ? '#ffb347' : '#e8fff6', p.fire ? 'ember' : 'foam', { g: 900, drag: 1 }); else if (p.fx === 'shout') { for (let i = 0; i < 9; i++) fxPart(x, y + (fxr() - .5) * 60, (fxr() - .5) * 420, -120 - fxr() * 240, .6, 16 + fxr() * 12, '#d8f1ff', 'glyph', { ch: FX_GLYPHS[Math.floor(fxr() * FX_GLYPHS.length)], g: 500, drag: 1.5 }); effects.push({ type: 'cf-ring', x, y, life: .3, maxLife: .3, color: '#8fd0ff', r: 70 }); } else if (p.fx === 'rings') effects.push({ type: 'cf-ring', x, y, life: .4, maxLife: .4, color: '#ff5a3a', r: 100 }); else if (p.fx === 'chain') { for (let i = 0; i < 6; i++) fxPart(x, y + (fxr() - .5) * 60, (fxr() - .5) * 220, -80 - fxr() * 160, .7, 14 + fxr() * 8, '#ffe9a6', 'glyph', { ch: fxr() < .5 ? '♪' : '♫', g: -40, drag: 1.5, edge: '#5a3a08' }); effects.push({ type: 'cf-ring', x, y, life: .35, maxLife: .35, color: '#ffd24a', r: 80 }); } else if (p.fx === 'spray') for (let i = 0; i < 14; i++) fxPart(x, y + (fxr() - .5) * 70, (fxr() - .3) * 300 * f, -200 * fxr(), .6, 4 + fxr() * 5, fxr() < .5 ? '#a6f04a' : '#d8ff7a', 'foam', { g: 700, drag: 1.2 }); else if (p.fx === 'ant') for (let i = 0; i < 4; i++) fxPart(x, y, (fxr() - .5) * 200, -80 - fxr() * 120, .35, 3, '#2a1a10', 'chip', { g: 800 }); else if (p.fx === 'drive') { dust(x, 8); for (let i = 0; i < 6; i++) fxPart(x, t.y - 10, (fxr() - .5) * 300, -200 - fxr() * 200, .5, 4, '#c9b48a', 'chip', { g: 1400, drag: .6 }); } } // Jurus area: cincin aba-aba (0,25 s sebelum kena, mengikuti timer aksi), lalu gelombang kejut, retakan dan serpihan. const FX_TELE = .25; function fxAreaBlast(x, R, col, big) { const gy = CONFIG.groundY; effects.push({ type: 'cf-shock', x, y: gy, life: big ? .6 : .5, maxLife: big ? .6 : .5, r: R, color: col }); const segs = [], n = 7 + Math.round(R / 30); for (let i = 0; i < n; i++) { const a = fxr() * 6.2832, len = R * (.45 + fxr() * .85), pts = [[0, 0]]; for (let j = 1; j <= 4; j++) { const u = j / 4, jit = (fxr() - .5) * 16; pts.push([Math.cos(a) * len * u + jit, Math.sin(a) * len * u * .24 + 7 + Math.abs(jit) * .25]); } segs.push(pts); } effects.push({ type: 'cf-crack', x, y: gy, life: 1.5, maxLife: 1.5, segs }); for (let i = 0; i < 12; i++) fxPart(x + (fxr() - .5) * R * .7, gy - 6, (fxr() - .5) * 360, -260 - fxr() * 300, .5 + fxr() * .3, 3 + fxr() * 4, fxr() < .5 ? '#8a6a45' : '#cdb78e', 'chip', { g: 1500, drag: .5 }); } function fxAreaTick(a, dt) { const act = a.action; if (!act || act.kind !== 'area' || act.fired || !act.pull) return; const hx = a.x + a.facing * (act.ahead || 0), tg = a === hero ? dummy : hero; if (!bwAlive(tg) || tg.y < CONFIG.groundY - 50 || Math.abs(tg.x - hx) > (act.radius || 100) * 2.6) return; const dx = hx - tg.x; tg.x = clamp(tg.x + Math.sign(dx) * Math.min(Math.abs(dx), act.pull * dt), tg === hero ? 70 : 110, tg === hero ? W - 70 : W - 100); } // Kemajuan aba-aba jurus area milik `a` (0..1), atau -1 bila tidak sedang aba-aba. function fxTeleP(a) { const act = a.action; if (!act || act.kind !== 'area' || act.fired) return -1; const hitT = act.duration * (act.hitAt ?? .5); return act.t < hitT - FX_TELE ? -1 : clamp((act.t - (hitT - FX_TELE)) / FX_TELE, 0, 1); } function fxDrawTele(a, col) { const act = a.action, p = fxTeleP(a); if (p < 0) return; const hx = a.x + a.facing * (act.ahead || 0), gy = CONFIG.groundY + 4, R = act.radius || 100, Rr = R * (1.55 - .55 * p); ctx.save(); ctx.lineCap = 'round'; ctx.globalAlpha = .12 + .22 * p; ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(hx, gy, R * p, R * p * .26, 0, 0, 7); ctx.fill(); ctx.globalAlpha = .35 + .6 * p; ctx.strokeStyle = col; ctx.lineWidth = 3 + 3 * p; ctx.beginPath(); ctx.ellipse(hx, gy, Rr, Rr * .26, 0, 0, 7); ctx.stroke(); ctx.globalAlpha = .5 * p; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.ellipse(hx, gy, R, R * .26, 0, 0, 7); ctx.stroke(); if (act.pull) for (let j = 0; j < 6; j++) { const ang = realTime * 7 + j * 1.047, q = (realTime * 1.6 + j / 6) % 1, rr = R * 2.2 * (1 - q); ctx.globalAlpha = .6 * (1 - q); ctx.fillStyle = col; ctx.beginPath(); ctx.arc(hx + Math.cos(ang) * rr, gy - 6 + Math.sin(ang) * rr * .26 - (1 - q) * 20, 4, 0, 7); ctx.fill(); } ctx.restore(); } // Dash: bayangan sprite yang memudar, debu di tanah dan garis kecepatan. const fxPool = []; function fxCv(w, h) { const c = fxPool.pop() || document.createElement('canvas'); c.width = w; c.height = h; return c; } function fxGhost(a, col) { let img, r, ax, ay, sx, sy; if (a === hero) { if (!images.hero || !manifest) return null; let list = frames(hero.state); if (!list.length) list = frames('idle'); if (!list.length) return null; const pose = heroPose(); r = list[pose.frame]; if (!r) return null; const cw = manifest.cell?.width || manifest.cell?.w || r.w, ch = manifest.cell?.height || manifest.cell?.h || r.h, sc = pose.scale * stateK(metrics, hero.state); img = images.hero; ax = manifest.cell?.anchor_x ?? cw / 2; ay = manifest.cell?.anchor_y ?? ch - (manifest.cell?.safe_margin_y || 0); sx = hero.facing * sc; sy = sc; } else { const pose = opponentPose(), m = pose.manifest, fm = CF[opponentCharacter]; if (!m || !fm) return null; img = images[cfFormOn(opponentCharacter, 'enemy') ? opponentCharacter + '-form' : opponentCharacter]; r = m.frame_layout?.rows?.[pose.state]?.[pose.frame]; if (!img || !r) return null; const k = stateK(cfFormOn(opponentCharacter, 'enemy') ? fm.mode?.form?.metrics : fm.metrics, pose.state); ax = m.cell.width / 2; ay = m.cell.height - m.cell.safe_margin_y; sx = (dummy.facing || -1) * k; sy = k; } const cv = fxCv(r.w, r.h), c2 = cv.getContext('2d'); c2.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h); c2.globalCompositeOperation = 'source-atop'; c2.globalAlpha = .7; c2.fillStyle = col; c2.fillRect(0, 0, r.w, r.h); return { cv, ax, ay, sx, sy, x: a.x, y: a.y, life: .24, max: .24 }; } function fxDashTick(a, dt) { const act = a.action; if (!act || act.kind !== 'dash' || Math.abs(a.vx) < 300 || act.t > act.duration * .72) return; act.fxT = (act.fxT || 0) - dt; if (act.fxT > 0) return; act.fxT = .03; const col = (a === hero ? CF[selectedCharacter] : CF[opponentCharacter])?.color || '#ffd36a', f = a.facing || 1, g = fxGhost(a, col); if (g) { cfx.ghosts.push(g); if (cfx.ghosts.length > 12) fxPool.push(cfx.ghosts.shift().cv); } spawnParticles(a.x - f * 24, CONFIG.groundY - 5, '#d6c6a3', 2, 70, 'dust'); for (let i = 0; i < 2; i++) fxPart(a.x - f * (40 + fxr() * 30), a.y - 15 - fxr() * 140, -f * (260 + fxr() * 200), 0, .16 + fxr() * .08, 2 + fxr() * 1.5, i ? '#ffffff' : col, 'streak', { len: 60 + fxr() * 70, g: 0, drag: .3 }); } // Selama dash, gambar jurusnya (banteng, mobil) ikut melaju di depan penyerang, di belakang sprite. function fxDrawDash(a, def) { const act = a.action; if (!def || !act || act.kind !== 'dash' || act.fired || act.t < act.duration * .12) return; const im = images['fx-' + def.id + '-' + act.asset]; if (!im) return; const k = clamp((act.t - act.duration * .12) / (act.duration * .2), 0, 1), size = (act.size || 220) * (.62 + .38 * easeOutBack(k)); ctx.save(); ctx.translate(Math.round(a.x + a.facing * 50), Math.round(a.y - 82)); ctx.scale(a.facing, 1); ctx.globalAlpha = .92 * k; ctx.imageSmoothingEnabled = true; ctx.drawImage(im, -size / 2, -size / 2, size, size); ctx.restore(); } function fxDrawGhosts() { for (const g of cfx.ghosts) { const k = g.life / g.max; ctx.save(); ctx.globalAlpha = k * .55; ctx.translate(Math.round(g.x), Math.round(g.y)); ctx.scale(g.sx, g.sy); ctx.drawImage(g.cv, -g.ax, -g.ay); ctx.restore(); } } // Satu langkah untuk semua pengatur waktu FX; dipanggil dari updateCf. function fxTick(dt) { for (const a of [hero, dummy]) { fxDashTick(a, dt); fxAreaTick(a, dt); } for (let n = cfx.ghosts.length - 1; n >= 0; n--) { const g = cfx.ghosts[n]; g.life -= dt; if (g.life <= 0) { fxPool.push(g.cv); cfx.ghosts.splice(n, 1); } } for (let n = cfx.later.length - 1; n >= 0; n--) { const l = cfx.later[n]; l.t -= dt; if (l.t <= 0) { cfx.later.splice(n, 1); l.fn(); } } for (let n = cfx.dots.length - 1; n >= 0; n--) { const d = cfx.dots[n], t = fxTarget(d.owner); d.t += dt; if (d.t > d.time || !bwAlive(t)) { cfx.dots.splice(n, 1); continue; } d.acc += d.dps * dt; if (d.acc >= 1) { const m = Math.floor(d.acc); d.acc -= m; d.shown += m; dotDamage(t, m, d.color, false); } if (d.shown >= 3) { damageNumbers.push({ x: t.x + (fxr() - .5) * 24, y: t.y - 130, amount: d.shown, life: .9, maxLife: .9, color: d.color }); d.shown = 0; } if (fxr() < dt * 16) fxPart(t.x + (fxr() - .5) * 56, t.y - 20 - fxr() * 110, 0, -50 - fxr() * 60, .8, 4 + fxr() * 4, d.color, 'bubble', { g: -30, drag: 1 }); } for (const h of cfx.hazards) if (h.fire && fxr() < dt * 9) fxPart(h.x + (fxr() - .5) * h.radius * 1.4, CONFIG.groundY - 8, (fxr() - .5) * 20, -60 - fxr() * 90, .7, 3 + fxr() * 3, '#ffb347', 'ember', { g: -30, drag: 1 }); } // ---- BRO WO status layer: cinematic camera, stun holds, poison drain, rage ------------------------------------------- // `bwx` is shared by the player's and the CPU's BRO WO; 'player' is the hero, 'enemy' is the dummy. const bwx = { zoom: 1, zoomT: 1, dim: 0, dimT: 0, cx: 640, cy: 480, rage: { player: 0, enemy: 0 }, poison: { player: null, enemy: null }, lock: { player: 0, enemy: 0 }, dance: [], giant: null, fires: [], burn: { player: 0, enemy: 0, shown: { player: 0, enemy: 0 } }, tornado: null }; function bwReset() { bwx.zoom = bwx.zoomT = 1; bwx.dim = bwx.dimT = 0; bwx.rage.player = bwx.rage.enemy = 0; bwx.poison.player = bwx.poison.enemy = null; bwx.lock.player = bwx.lock.enemy = 0; bwx.dance.length = 0; bwx.giant = null; bwx.fires.length = 0; bwx.burn.player = bwx.burn.enemy = 0; bwx.burn.shown.player = bwx.burn.shown.enemy = 0; bwx.tornado = null; cfx.ents.length = cfx.hazards.length = cfx.marks.length = cfx.later.length = cfx.dots.length = cfx.ghosts.length = 0; hero.bwRage = dummy.bwRage = false; hero.bwSaved = dummy.bwSaved = null; { const b = bwBaseCooldowns(selectedCharacter); if (b) Object.assign(cooldownMax, b); if (CF[selectedCharacter]?.mode?.form) syncPlayerForm(); } } // Rage swaps the specials: skills 1/2 and the ultimate start fresh (their own cooldowns, see BW.rageBalance); the normal // cooldowns are put back when the rampage ends. // The post-ultimate "mode": BRO WO's Mengamuk (bespoke) or CF[id].mode (data). Same machinery for both: timer in bwx.rage[side], // a.bwRage tells the kit to use the mode's skills, cooldowns swap in and out, and an optional `form` swaps the whole atlas. // Durasi mode memakai perk karakter (modeDur); gaya tidak mengubahnya, jadi angkanya sama di kedua sisi dan di bar ultimate. const modeDurMod = id => window.Passives?.info(id)?.mods?.modeDur ?? 1; function modeOf(id) { if (id === 'browo') return BW ? { duration: BW.rage.duration * modeDurMod(id), aura: '#ff6e28', label: 'MENGAMUK', announce: 'MENGAMUK!', names: BW.rageNames, icons: ['basic', 'rage-skill1', 'rage-skill2', 'rage-ultimate'], cooldowns: BW.rageBalance.cooldowns, speedMul: BW.rage.speedMul } : null; const m = CF[id]?.mode; if (!m) return null; return { duration: (m.duration || 10) * modeDurMod(id), aura: m.aura || '#ff5a3c', label: m.label || 'MODE', announce: m.announce || 'MODE!', names: m.names, icons: ['basic', 'mode-skill1', 'mode-skill2', 'mode-ultimate'], cooldowns: { skill1: m.cooldowns?.skill1 ?? 5, skill2: m.cooldowns?.skill2 ?? 8, ultimate: 999 }, speedMul: m.speedMul || 1.15 }; } function cfFormOn(id, side) { return !!(CF[id]?.mode?.form && bwx.rage[side] > 0); } function bwBaseCooldowns(id) { return id === 'browo' ? BW.balance.cooldowns : CF[id] ? CF[id].balance.cooldowns : null; } function bwEnterRage(key) { const a = key === 'player' ? hero : dummy, id = key === 'player' ? selectedCharacter : opponentCharacter, M = modeOf(id); if (!M) return; bwx.rage[key] = M.duration; a.bwSaved = { skill1: a.cooldowns.skill1, skill2: a.cooldowns.skill2, ultimate: a.cooldowns.ultimate }; a.cooldowns.skill1 = a.cooldowns.skill2 = a.cooldowns.ultimate = 0; a.bwRage = true; if (key === 'player') { if (CF[id]?.mode?.form) syncPlayerForm(); Object.assign(cooldownMax, M.cooldowns); } hudIdentity = ''; } function bwExitRage(key) { const a = key === 'player' ? hero : dummy, id = key === 'player' ? selectedCharacter : opponentCharacter; bwx.rage[key] = 0; a.bwRage = false; if (a.bwSaved) { for (const n of ['skill1', 'skill2', 'ultimate']) a.cooldowns[n] = a.bwSaved[n]; a.bwSaved = null; } if (key === 'player') { if (CF[id]?.mode?.form) syncPlayerForm(); const b = bwBaseCooldowns(id); if (b) Object.assign(cooldownMax, b); } hudIdentity = ''; } function bwAlive(target) { return target === dummy ? !dummy.ko && dummy.state !== 'down' && dummy.state !== 'recover' : hero.hp > 0; } // Damage over time / scripted damage: no hurt animation, no knockback, no cooldown refunds; K.O. still applies. // Only discrete scripted bursts (combo = true) count as combo hits; per-tick DoT must not feed the combo rank, the damage reducer or coins. function dotDamage(target, amount, color = '#9bd05a', numbers = true, combo = false) { if (amount <= 0 || menuOpen || (match && match.phase !== 'fight') || !bwAlive(target)) return false; amount = passiveDamage(target === dummy ? hero : dummy, target, amount, false); target.lastHurt = time; if (amount <= 0) return true; if (target === dummy) { dummy.damage += amount; totalDamage += amount; if (combo) { comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboTimer = 2.3; } if (combo) window.MatchStats?.hit(null, false, comboHits, null); if (combo || comboTimer > 0) comboDamage += amount; if (numbers) damageNumbers.push({ x: dummy.x + (random() - .5) * 30, y: dummy.y - 120, amount, kind: Feel.classify(amount), life: .9, maxLife: .9, color }); if (amount >= Feel.table.medium) dummy.flash = dummy.flashMax = Feel.table.hit.medium.flash; if (dummy.damage >= BALANCE.dummyMax) { dummy.ko = true; dummy.state = 'down'; dummy.vy = -330; dummy.vx = 0; dummy.fallDirection = hero.x <= dummy.x ? 1 : -1; announce('K.O.', .9); } return true; } amount = foeDamage(amount); hero.hp = Math.max(0, hero.hp - amount); if (numbers) damageNumbers.push({ x: hero.x + (random() - .5) * 30, y: hero.y - 120, amount, kind: Feel.classify(amount), life: .9, maxLife: .9, color }); if (amount >= Feel.table.medium) hero.flash = hero.flashMax = Feel.table.hit.medium.flash; if (!hero.hp) { hero.action = null; hero.hurtTime = 0; state('down'); hero.koTime = 1.7; clearInput(); if (match?.mode !== 'versus') announce('CORE RESTART', 1.7); } return true; } function bwLock(key, seconds) { bwx.lock[key] = Math.max(bwx.lock[key], seconds); } function bwPoison(key) { const D = BW.dance, p = bwx.poison[key]; if (p) p.t = D.poisonTime; else bwx.poison[key] = { t: D.poisonTime, acc: 0, shown: 0 }; } function updateBrowo(dt) { const D = BW ? BW.dance : null; if (!D) return; hero.bwRage = !!modeOf(selectedCharacter) && bwx.rage.player > 0; dummy.bwRage = !!modeOf(opponentCharacter) && bwx.rage.enemy > 0; // Dance bursts: each burst that reaches the rival stuns them, deals damage and refreshes the poison. for (let n = bwx.dance.length - 1; n >= 0; n--) { const d = bwx.dance[n], caster = d.owner === 'enemy' ? dummy : hero, target = d.owner === 'enemy' ? hero : dummy, tk = d.owner === 'enemy' ? 'player' : 'enemy'; d.t += dt; if (caster === hero ? hero.hp <= 0 || hero.state === 'hurt' || hero.state === 'down' : dummy.ko || dummy.state === 'hurt' || dummy.state === 'down') { bwx.dance.splice(n, 1); continue; } while (d.pulses < D.pulses && d.t >= D.first + d.pulses * D.gap) { d.pulses++; effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: caster.x, y: caster.y - 95, facing: 1, life: .5, maxLife: .5, size: 320 }); spawnParticles(caster.x, caster.y - 95, '#8fd14f', 12, 260); trauma = Math.min(1, trauma + .12); sound('heavy'); if (Math.abs(target.x - caster.x) < D.radius && Math.abs(target.y - caster.y) < 130 && bwAlive(target)) { dotDamage(target, D.pulseDamage, '#cfe9a0', true, true); bwLock(tk, D.gap + D.stunExtra); bwPoison(tk); spawnParticles(target.x, target.y - 85, '#7be04a', 10, 200); } } if (d.pulses >= D.pulses) bwx.dance.splice(n, 1); } // Holds: the rival stays in a hurt pose, without moving or acting, while a lock is active. for (const key of ['player', 'enemy']) { if (bwx.lock[key] <= 0) continue; bwx.lock[key] = Math.max(0, bwx.lock[key] - dt); if (key === 'enemy') { if (bwAlive(dummy)) { dummy.vx = 0; dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = Math.min(dummy.stateTime, .2); } } else if (hero.hp > 0) { hero.vx = 0; hero.action = null; hero.hurtTime = .25; if (hero.state !== 'hurt') state('hurt'); } } // Poison drain. for (const key of ['player', 'enemy']) { const p = bwx.poison[key]; if (!p) continue; const target = key === 'player' ? hero : dummy; p.t -= dt; p.acc += D.poisonDps * dt; if (p.acc >= 1) { const n = Math.floor(p.acc); p.acc -= n; p.shown += n; dotDamage(target, n, '#7be04a', false); } if (p.shown >= 3) { damageNumbers.push({ x: target.x + (random() - .5) * 24, y: target.y - 130, amount: p.shown, life: .9, maxLife: .9, color: '#7be04a' }); p.shown = 0; } if (p.t <= 0 || !bwAlive(target)) bwx.poison[key] = null; } // Rage countdown. for (const key of ['player', 'enemy']) if (bwx.rage[key] > 0) { const grp = key === 'player' ? sunroar : enemySunroar; if (grp && (grp.kind === 'mode' || grp.kind === 'tornado')) continue; // the mode's own ultimate holds the timer and ends the mode itself bwx.rage[key] = Math.max(0, bwx.rage[key] - dt); if (bwx.rage[key] === 0) bwExitRage(key); } // Hutan Terbakar: each palm ignites once, then burns anyone standing on the ground inside its radius (one burn per rival). const F2 = BW.fire, inside = { player: false, enemy: false }; for (let n = bwx.fires.length - 1; n >= 0; n--) { const f = bwx.fires[n], target = f.owner === 'enemy' ? hero : dummy, tk = f.owner === 'enemy' ? 'player' : 'enemy'; f.t += dt; if (f.t < 0) continue; const near = Math.abs(target.x - f.x) < F2.radius && target.y > CONFIG.groundY - 120 && bwAlive(target); if (!f.lit) { f.lit = true; effects.push({ type: 'browo-fx', asset: 'browo-impact', x: f.x, y: CONFIG.groundY - 30, facing: 1, life: .4, maxLife: .4, size: 200 }); spawnParticles(f.x, CONFIG.groundY - 40, '#ff8a3a', 10, 220); sound('hit'); if (near) dotDamage(target, F2.ignite, '#ff9a4a', true, true); } if (near) inside[tk] = true; if (f.t > F2.life) bwx.fires.splice(n, 1); } for (const key of ['player', 'enemy']) { if (!inside[key]) continue; const target = key === 'player' ? hero : dummy; bwx.burn[key] += F2.dps * dt; if (bwx.burn[key] >= 1) { const m = Math.floor(bwx.burn[key]); bwx.burn[key] -= m; bwx.burn.shown[key] += m; dotDamage(target, m, '#ff9a4a', false); } if (bwx.burn.shown[key] >= 3) { damageNumbers.push({ x: target.x + (random() - .5) * 24, y: target.y - 130, amount: bwx.burn.shown[key], life: .9, maxLife: .9, color: '#ff9a4a' }); bwx.burn.shown[key] = 0; } } // Camera eases toward the targets set by the ultimate; with no ultimate running they are neutral. if (!sunroar && !enemySunroar) { bwx.zoomT = 1; bwx.dimT = 0; bwx.giant = null; bwx.tornado = null; } const k = Math.min(1, dt * 7); bwx.zoom += (bwx.zoomT - bwx.zoom) * k; bwx.dim += (bwx.dimT - bwx.dim) * k; if (Math.abs(bwx.zoom - 1) < .002 && bwx.zoomT === 1) bwx.zoom = 1; if (bwx.dim < .004 && bwx.dimT === 0) bwx.dim = 0; } // BRO WO Hutan Sawit: the group object is the shared `sunroar` slot, so pause/reset/cut-in plumbing is SOLAN's; `roars` // counts the small palms raised and `giant` is the final palm. function updateForest(dt, s) { const U = BW.forest, a = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, tk = s.owner === 'enemy' ? 'player' : 'enemy', ok = s.owner === 'enemy' ? 'enemy' : 'player'; s.t += dt; const mx = (a.x + target.x) / 2; // Camera script. if (s.t < U.treesAt) { bwx.zoomT = 1.75; bwx.dimT = .82; bwx.cx = a.x; bwx.cy = a.y - 90; } else if (s.t < U.giantAt) { bwx.zoomT = 1.2; bwx.dimT = .78; bwx.cx = mx; bwx.cy = a.y - 90; } else if (s.t < U.release + .35) { bwx.zoomT = 1.12; bwx.dimT = .7; bwx.cx = mx; bwx.cy = a.y - 130; } else { bwx.zoomT = 1; bwx.dimT = 0; } if (s.t >= .15 && s.t < U.treesAt) trauma = Math.min(.35, trauma + dt * .5); if (s.t >= U.lockAt && s.t < U.release && bwAlive(target)) bwLock(tk, .25); // Ten palms in a rapid run from BRO WO toward (and a little past) the rival. const dist = Math.abs(target.x - a.x); while (s.roars < U.treeCount && s.t >= U.treesAt + s.roars * U.treeGap) { const i = s.roars++, x = clamp(a.x + s.facing * (150 + i * (dist - 50) / (U.treeCount - 1)), 70, W - 70); effects.push({ type: 'browo-tree', x, y: CONFIG.groundY, life: .6, maxLife: .6 }); effects.push({ type: 'browo-fx', asset: 'browo-impact', x, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); spawnParticles(x, CONFIG.groundY - 10, '#a47a3a', 10, 240, 'dust'); trauma = Math.min(1, trauma + .18); sound('hit'); if (bwAlive(target)) dotDamage(target, U.treeDamage, '#9bd05a', true, true); } // The giant palm: rises, leans back, then comes down on the rival. if (!s.giant && s.t >= U.giantAt) { s.giant = bwx.giant = { t: 0, x: a.x + s.facing * 26, dir: s.facing, L: clamp(dist * 1.7 + 300, 760, 1250), hit: false }; sound('heavy'); trauma = Math.min(1, trauma + .3); } if (s.giant) { const g = s.giant; g.t += dt; const slamAt = U.giantRise + U.giantHold; if (!g.hit && g.t >= slamAt + U.slam) { g.hit = true; const tx = clamp(a.x + s.facing * dist, 70, W - 70); effects.push({ type: 'browo-fx', asset: 'browo-impact', x: tx, y: CONFIG.groundY - 30, facing: 1, life: .6, maxLife: .6, size: 520 }); effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: tx, y: CONFIG.groundY - 120, facing: 1, life: .5, maxLife: .5, size: 520 }); for (let q = 0; q < 3; q++) spawnParticles(tx, CONFIG.groundY - 12, q === 1 ? '#fff1c8' : '#a47a3a', 22, 460, 'dust'); trauma = 1; hitstop = Math.max(hitstop, .14); sound('heavy'); if (bwAlive(target)) { if (target === dummy) { if (!hitDummy(U.giantDamage, 360, '#ffe08a', a.x, CONFIG.groundY - 100, true, true, 2)) dotDamage(dummy, U.giantDamage, '#ffe08a', true, true); } else if (!window.__game.receiveHit(U.giantDamage, { shake: 2 })) dotDamage(hero, U.giantDamage, '#ffe08a'); } } } s.phase = s.t < U.cutinDuration ? 'cutin' : s.t < U.treesAt ? 'roaring' : 'fading'; if (s.t >= U.duration) { bwEnterRage(ok); bwx.giant = null; announce('MENGAMUK!', 1.3); effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: a.x, y: a.y - 95, facing: 1, life: .6, maxLife: .6, size: 380 }); if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } } // BRO WO rage ultimate, Tornado Api Sawit: the camera dives on him, a fire tornado is born and hunts the rival; inside its // radius the rival is held, dragged to the core and burned; it ends in an explosion (and the rampage ends with it). function updateTornado(dt, s) { const U = BW.tornado, a = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, tk = s.owner === 'enemy' ? 'player' : 'enemy', ok = s.owner === 'enemy' ? 'enemy' : 'player'; s.t += dt; if (s.t < U.spawnAt) { bwx.zoomT = 1.65; bwx.dimT = .85; bwx.cx = a.x; bwx.cy = a.y - 90; if (s.t > .12) trauma = Math.min(.4, trauma + dt * .6); } else if (s.t < U.spawnAt + U.life) { bwx.zoomT = 1.18; bwx.dimT = .6; bwx.cx = s.tw ? s.tw.x : a.x; bwx.cy = a.y - 120; } else { bwx.zoomT = 1; bwx.dimT = 0; } if (!s.tw && s.t >= U.spawnAt) { s.tw = bwx.tornado = { x: clamp(a.x + s.facing * 70, 60, W - 60), age: 0, tick: 0, blasted: false }; effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: s.tw.x, y: CONFIG.groundY - 100, facing: 1, life: .6, maxLife: .6, size: 460 }); sound('heavy'); trauma = Math.min(1, trauma + .45); } const tw = s.tw; if (tw) { tw.age += dt; if (tw.age < U.life) { if (bwAlive(target)) tw.x += clamp(target.x - tw.x, -U.chase * dt, U.chase * dt); tw.x = clamp(tw.x, 60, W - 60); if (bwAlive(target) && Math.abs(target.x - tw.x) < U.radius) { bwLock(tk, .25); target.x += (tw.x - target.x) * Math.min(1, dt * 3.2); target.vx = 0; tw.tick -= dt; if (tw.tick <= 0) { tw.tick += U.tick; dotDamage(target, U.tickDamage, '#ff9a4a'); sound('hit'); } } if (Math.sin(realTime * 33) > .5) spawnParticles(tw.x + (random() - .5) * 90, CONFIG.groundY - random() * 260, '#ff7a30', 1, 160); trauma = Math.min(.5, trauma + dt * .4); } else if (!tw.blasted) { tw.blasted = true; effects.push({ type: 'browo-fx', asset: 'browo-impact', x: tw.x, y: CONFIG.groundY - 40, facing: 1, life: .6, maxLife: .6, size: 560 }); effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: tw.x, y: CONFIG.groundY - 150, facing: 1, life: .55, maxLife: .55, size: 620 }); for (let q = 0; q < 3; q++) spawnParticles(tw.x, CONFIG.groundY - 60, q === 1 ? '#fff1c8' : '#ff7a30', 24, 520); trauma = 1; hitstop = Math.max(hitstop, .14); sound('heavy'); if (bwAlive(target) && Math.abs(target.x - tw.x) < U.radius * 1.4) { if (target === dummy) { if (!hitDummy(U.blast, 400, '#ffb060', tw.x, CONFIG.groundY - 100, true, true, 2)) dotDamage(dummy, U.blast, '#ffb060', true, true); } else if (!window.__game.receiveHit(U.blast, { shake: 2 })) dotDamage(hero, U.blast, '#ffb060'); } } } s.phase = s.t < U.cutinDuration ? 'cutin' : 'roaring'; if (s.t >= U.duration) { bwx.tornado = null; bwExitRage(ok); if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } } // NIB Special Delivery: three paper-plane parcels leave the satchel 0.38 s apart and home in on the rival (homing // projectiles with a limited turn rate, 16 each, 48 total). NIB is free after the 0.5 s cast; a late sidestep or a // jump makes a plane overshoot. function startDelivery(actor = hero) { const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, released: 0, phase: 'cutin' }; if (actor === hero) delivery = group; else enemyDelivery = group; cinematic = .9; announceTimer = 0; startUltimateVoice('nib', actor); } function updateDelivery(dt, s) { if (!s) return; const U = NB.delivery, a = s.owner === 'enemy' ? dummy : hero; s.t += dt; while (s.released < U.releases.length && s.t >= U.releases[s.released]) { const last = s.released === U.releases.length - 1; s.released++; const x = a.x + s.facing * 30, y = a.y - 110; projectiles.push({ x, y, vx: s.facing * U.speed * .6, vy: -U.speed * .8, life: U.life, facing: s.facing, owner: s.owner, damage: NB.balance.planeDamage, knockback: last ? 180 : 40, asset: 'nib-plane', size: U.size, color: '#8fd4ff', homing: true, speedH: U.speed, turn: U.turn, freeze: false }); effects.push({ type: 'nib-fx', asset: 'nib-stamp', x, y, facing: s.facing, life: .3, maxLife: .3, size: 130 }); spawnParticles(x, y, '#f4efe2', 10, 200); sound('cast'); } s.phase = s.t < U.cutinDuration ? 'cutin' : s.released < U.releases.length ? 'releasing' : 'flying'; if (s.t >= U.duration && !projectiles.some(p => p.asset === 'nib-plane' && p.owner === s.owner)) { if (s.owner === 'enemy') enemyDelivery = null; else delivery = null; } } // EDDA Elder Tortoise: a giant jade tortoise spirit rises in front of EDDA, lumbers forward and stomps three times // 0.38 s apart (16 each, 48). A stomp only hits a rival on the floor within ED.tortoise.half px of the spirit. function startTortoise(actor = hero) { const U = ED.tortoise, group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, x: clamp(actor.x + actor.facing * U.ahead, 60, W - 60), stomped: 0, phase: 'cutin', spirit: null }; if (actor === hero) tortoise = group; else enemyTortoise = group; cinematic = .9; announceTimer = 0; startUltimateVoice('edda', actor); } function updateTortoise(dt, s) { if (!s) return; const U = ED.tortoise; s.t += dt; if (!s.spirit && s.t >= U.appear) { s.spirit = { type: 'edda-spirit', asset: 'edda-tortoise', x: s.x, y: CONFIG.groundY + 8, facing: s.facing, life: U.duration - U.appear, maxLife: U.duration - U.appear, size: U.size, lift: 0 }; effects.push(s.spirit); spawnParticles(s.x, CONFIG.groundY - 20, '#8fe3b4', 24, 260); sound('cast'); } if (s.spirit) { s.x = clamp(s.x + s.facing * U.speed * dt, 60, W - 60); s.spirit.x = s.x; s.spirit.lift = U.stomps.reduce((m, ts) => Math.max(m, 34 * Math.max(0, 1 - Math.abs(s.t - (ts - .16)) / .16)), 0); } while (s.stomped < U.stomps.length && s.t >= U.stomps[s.stomped]) { const last = s.stomped === U.stomps.length - 1, target = s.owner === 'enemy' ? hero : dummy; s.stomped++; effects.push({ type: 'edda-fx', asset: 'edda-stomp', x: s.x + s.facing * 50, y: CONFIG.groundY - 30, facing: s.facing, life: .35, maxLife: .35, size: 220 }); spawnParticles(s.x + s.facing * 50, CONFIG.groundY - 8, '#d8c089', 22, 320); sound('heavy'); trauma = Math.min(1, trauma + .22); if (Math.abs(target.x - s.x) < U.half && target.y > CONFIG.groundY - 40) { if (s.owner === 'enemy') window.__game.receiveHit(ED.balance.stompDamage, { freeze: false }); else hitDummy(ED.balance.stompDamage, last ? 200 : 40, '#8fe3b4', s.x, CONFIG.groundY - 60, false, false); } } s.phase = s.t < U.cutinDuration ? 'cutin' : s.stomped < U.stomps.length ? 'stomping' : 'fading'; if (s.t >= U.duration) { if (s.owner === 'enemy') enemyTortoise = null; else tortoise = null; } } // EDDA Shell Counter: the first hit inside the guard window is ignored (EDDA ducks into her shell) and an attacker // within ED.counter.reach px is answered with a staff counter. A shot from further away is simply blocked. function shellGuard(actor) { const a = actor.action; if (!a?.guard || a.countered || a.t >= a.guardEnd) return false; a.countered = true; if (a.t < .34) { a.t = .34; actor.stateTime = .34; } const target = actor === hero ? dummy : hero, em = window.EDDA_METRICS?.emitters?.skill2, dx = target.x - actor.x; effects.push({ type: 'edda-fx', asset: 'edda-shell', x: actor.x, y: actor.y - 80, facing: actor.facing, life: .35, maxLife: .35, size: 170 }); spawnParticles(actor.x, actor.y - 80, '#e8d9a8', 14, 240); sound('heavy'); if (Math.abs(dx) < ED.counter.reach && Math.abs(target.y - actor.y) < 115) { actor.facing = Math.sign(dx) || actor.facing; const hy = actor.y + (em?.y ?? -90); effects.push({ type: 'whip', x: actor.x, y: hy, facing: actor.facing, tip: em?.x ?? 130, life: .16, maxLife: .16, index: 3, color: '#ffe9a8' }); if (actor === hero) hitDummy(ED.counter.damage, ED.counter.knockback, '#ffe9a8', actor.x, hy, false, true); else window.__game.receiveHit(ED.counter.damage); } return true; } function droneMuzzle(d, facing) { const muzzle = window.DRONE_ASSET?.muzzle || { x: 51, y: 0 }; const c = Math.cos(d.angle), s = Math.sin(d.angle), x = facing * muzzle.x, y = muzzle.y; return { x: d.x + x * c - y * s, y: d.y + x * s + y * c }; } function step(realDt) { systemAnnouncer?.tick(realDt); if (menuOpen || paused || settingsOpen) return; // K.O. slow motion (feel.js) hanya memperlambat simulasi visual (dt); jam ronde/penutup K.O. di advanceMatch tetap waktu nyata. Feel.tick(realDt); const dt = realDt * Feel.ko.scale(); hero.flash = Math.max(0, (hero.flash || 0) - realDt); dummy.flash = Math.max(0, (dummy.flash || 0) - realDt); if(match?.mode==='versus'&&match.phase!=='fight') { // Shake is driven by realTime, so trauma must keep decaying here or the K.O./intro screen vibrates. realTime+=realDt;trauma=Math.max(0,trauma-realDt*1.35);cinematic=Math.max(0,cinematic-realDt);hero.animTime+=dt;dummy.stateTime+=dt;updateVisuals(dt); if(hero.y 0) { hitstop -= realDt; return; } time += dt; hero.animTime += dt; hero.invuln = Math.max(0, hero.invuln - dt); hero.doublePose = Math.max(0, (hero.doublePose || 0) - dt); if(fenrCutin) {fenrCutin.t+=dt;if(fenrCutin.t>.78)fenrCutin=null;} if(F&&selectedCharacter==='fenr'){const changes=hero.formChanges;F.advance(hero,dt,hero.hp>0);if(changes!==hero.formChanges){syncPlayerForm();hero.doublePose=0;state(hero.grounded?'idle':'jump');}} hero.airTime = hero.grounded ? 0 : hero.airTime + dt; hero.spCd = Math.max(0, (hero.spCd || 0) - dt); hero.throwCd = Math.max(0, (hero.throwCd || 0) - dt); hero.shopCd = Math.max(0, (hero.shopCd || 0) - dt); hero.shield = Math.max(0, (hero.shield || 0) - dt); for (const name of Object.keys(hero.cooldowns)) hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - dt * cdRate(hero, name)); window.Access?.capCooldowns?.(hero.cooldowns); passiveRegen(dt); // "Tanpa cooldown" (access.js) if (hero.doublePose <= 0 && hero.rollBuffer && hero.hurtTime <= 0) { const buffered = hero.rollBuffer; hero.rollBuffer = null; if (buffered === 'attack') startAttack(); else cast(buffered); } comboTimer = Math.max(0, comboTimer - dt); if (!comboTimer) comboHits = comboDamage = 0; hero.parry = Math.max(0, (hero.parry || 0) - dt); hero.guardBreak = Math.max(0, (hero.guardBreak || 0) - dt); hero.guard = Math.max(0, (hero.guard || 0) - dt * 22); hero.autoGuard = Math.max(0, (hero.autoGuard || 0) - dt); hero.blocking = (keys.has('l') || hero.autoGuard > 0) && hero.grounded && !hero.action && hero.hurtTime <= 0 && hero.hp > 0 && hero.guardBreak <= 0 && hero.doublePose <= 0; const dir = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0), crouching = (keys.has('s') || hero.blocking) && hero.grounded && !hero.action; if (!hero.action && hero.hurtTime <= 0 && hero.hp > 0) { if (dir && hero.doublePose <= 0) hero.facing = dir; if (hero.runDirection && dir !== hero.runDirection) hero.runDirection = 0; const speed = (hero.runDirection === dir && dir ? CONFIG.runSpeed : CONFIG.walkSpeed)*(selectedCharacter==='fenr'&&hero.form==='wolf'?1.08:1)*(bwx.rage.player>0?(modeOf(selectedCharacter)?.speedMul||1):1)*passiveMod(hero,'walk'); hero.vx = approach(hero.vx, crouching || hero.blocking ? 0 : dir * speed, dt * (hero.grounded ? 2800 : 1600)); if (hero.grounded) state(crouching ? 'crouch' : dir ? hero.runDirection ? 'run' : 'walk' : 'idle'); else state(hero.doublePose > 0 ? 'doublejump' : 'jump'); } else hero.vx = approach(hero.vx, 0, dt * 1900); if (hero.hurtTime > 0) { hero.hurtTime -= dt; state('hurt'); if (hero.hurtTime <= 0) hero.invuln = .9; } if(hero.hp<=0) { hero.koTime=Math.max(0,hero.koTime-dt); state('down'); if(!hero.koTime&&match?.mode!=='versus') {hero.hp=BALANCE.heroMax;hero.invuln=.9;state(hero.grounded?'idle':'jump');} } if (hero.action) { const a = hero.action; a.t += dt; state(a.name); if((selectedCharacter==='fenr'||selectedCharacter==='mira'||selectedCharacter==='haldor'||selectedCharacter==='isolde'||selectedCharacter==='solan'||selectedCharacter==='nib'||CF[selectedCharacter])&&a.dash&&a.t>a.duration*.2&&a.t= a.duration * (a.hitAt || (a.type === 'skill2' ? .53 : .5))) { a.fired = true; strikeAs(a.type === 'attack' && !a.special ? 'basic' : null, () => { if (a.special) specialHit(a); else if (a.type === 'attack') meleeHit(a.index); else fireSkill(a); }); } if (a.fired && a.cancelInto && hero.action === a) { const into = a.cancelInto; hero.action = null; state(hero.grounded ? 'idle' : 'jump'); if (into === 'jump') jump(); } if (a.t >= a.duration || (a.type === 'skill1' && a.fired)) { const queued = a.queued && a.index < 3, remaining = Math.max(0, a.queued - 1); const next = (a.index || 0) + 1; hero.action = null; if (queued) { startAttack(next); hero.action.queued = remaining; } else state(hero.grounded ? 'idle' : 'jump'); } } const oldHeroX = hero.x; hero.x = clamp(hero.x + hero.vx * dt, 70, W - 70); const gap = bodyGap(); if (dummy.state !== 'down' && dummy.state !== 'recover' && hero.y > CONFIG.groundY - 130 && dummy.y > CONFIG.groundY - 130 && Math.abs(hero.x - dummy.x) < gap) { hero.x = clamp(dummy.x + (oldHeroX <= dummy.x ? -gap : gap), 70, W - 70); hero.vx = 0; } const travelled=Math.abs(hero.x-oldHeroX); if(hero.grounded && hero.state==='run' && travelled>0) { hero.smokeDistance+=travelled; while(hero.smokeDistance>=30) { hero.smokeDistance-=30; runSmoke(hero.x,hero.facing); } } else hero.smokeDistance=0; if (!hero.grounded) { if (keys.has('s') && hero.vy > 0 && hero.doublePose <= 0) hero.vy = Math.max(hero.vy, CONFIG.fastFall); hero.vy += CONFIG.gravity * dt; hero.y += hero.vy * dt; if (hero.y >= CONFIG.groundY) { hero.y = CONFIG.groundY; hero.vy = 0; hero.grounded = true; hero.jumps = 0; hero.doublePose = 0; hero.airTime = 0; dust(hero.x, 11); sound('step'); } } const stride = metrics?.states?.[hero.state]?.stride_estimate || manifest?.metrics?.[hero.state === 'run' ? 'runStride' : 'walkStride'] || manifest?.locomotion?.[hero.state]?.stride_px || (hero.state === 'run' ? 184 : 148); hero.walkPhase += Math.abs(hero.vx) * dt / stride; updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt);updateCf(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || CF[voiceKind] || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt; if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); feelWatchKo(); advanceMatch(realDt); } // MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them. function bodyGap() { return CONFIG.bodyGap || 80; } function currentDummyHP() {return Math.max(0,BALANCE.dummyMax-dummy.damage);} function hpLayers(value) {return {front:clamp(value-BALANCE.hpPerBar,0,BALANCE.hpPerBar),reserve:clamp(value,0,BALANCE.hpPerBar)};} function updateDummy(dt) { if(F){for(const name of Object.keys(dummy.cooldowns))dummy.cooldowns[name]=Math.max(0,dummy.cooldowns[name]-dt*cdRate(dummy,name));if(opponentCharacter==='fenr')F.advance(dummy,dt,!dummy.ko);} dummy.stateTime += dt; dummy.invuln = Math.max(0, dummy.invuln - dt); dummy.x = clamp(dummy.x + dummy.vx * dt, 110, W - 100); if(!F || (!aiEnabled && !p2Mode) || dummy.action || ['hurt','down','recover'].includes(dummy.state))dummy.vx = approach(dummy.vx, 0, dt * 950); if(F && !['down','recover'].includes(dummy.state) && hero.y>CONFIG.groundY-130 && dummy.y>CONFIG.groundY-130 && Math.abs(dummy.x-hero.x)=hero.x?bodyGap():-bodyGap()),110,W-100); // Versus CPU follows the player's post-hurt immunity rule by difficulty, so a mashed chain cannot loop it forever. // Its stun lasts 0.5 s there, which still covers every 3-hit chain gap; the training dummy stays open for practice. if (dummy.state === 'hurt' && dummy.stateTime > (F && aiEnabled && match?.mode === 'versus' ? .5 : .4)) { dummy.state = dummy.y < CONFIG.groundY ? 'jump' : 'idle'; dummy.stateTime = 0; if (F && aiEnabled && match?.mode === 'versus') dummy.invuln = Math.max(dummy.invuln, cpuProfile().immunity); } if (dummy.state !== 'down' && (dummy.y < CONFIG.groundY || dummy.vy < 0)) { dummy.vy += CONFIG.gravity * dt; dummy.y += dummy.vy * dt; if (dummy.y >= CONFIG.groundY) { dummy.y = CONFIG.groundY; dummy.vy = 0; dummy.jumps = 0; cpu.doubleAt = 0; if (dummy.state === 'jump') { dummy.state = 'idle'; dummy.stateTime = 0; } dust(dummy.x, 8); } } if (dummy.state === 'down') { if (dummy.y < CONFIG.groundY || dummy.vy < 0) { dummy.vy += CONFIG.gravity * dt; dummy.y += dummy.vy * dt; if (dummy.y >= CONFIG.groundY) { dummy.y = CONFIG.groundY; dummy.vy = 0; dust(dummy.x, 24); trauma = Math.min(1, trauma + .3); } } if (dummy.stateTime > 1.7 && (match?.mode!=='versus' || !dummy.ko)) { dummy.state = 'recover'; dummy.stateTime = 0; } } if (dummy.state === 'recover' && dummy.stateTime > .4) { dummy.state = 'idle'; dummy.stateTime = 0; if(dummy.ko) dummy.damage = 0; dummy.ko=false; dummy.invuln = .5; } if(F)updateFenrAI(dt); } // HALDOR Slag Shot landing: a small molten splash on the floor where the lob comes down. function slagSplash(p) { effects.push({ type: 'haldor-fx', asset: 'haldor-splash', x: p.x, y: CONFIG.groundY - 26, facing: p.facing, life: .4, maxLife: .4, size: 150 }); spawnParticles(p.x, CONFIG.groundY - 12, '#ff9a3c', 16, 260); sound('heavy'); trauma = Math.min(1, trauma + .12); if (p.owner === 'enemy') { if (Math.abs(hero.x - p.x) < p.splash && hero.y > CONFIG.groundY - 60) window.__game.receiveHit(p.damage, { projectile: true }); } else if (Math.abs(dummy.x - p.x) < p.splash && dummy.y > CONFIG.groundY - 60) hitDummy(p.damage, p.knockback, p.color, p.x - p.facing * 20, CONFIG.groundY - 45, false, false); } function updateProjectiles(dt) { for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i], oldX = p.x; if (p.wait > 0) { p.wait -= dt; continue; } // konvoi: menunggu giliran // NIB paper planes steer toward the rival's chest, turning at most p.turn rad/s. if (p.homing) { const t = p.owner === 'enemy' ? hero : dummy, want = Math.atan2(t.y - 85 - p.y, t.x - p.x), cur = Math.atan2(p.vy, p.vx); let d = want - cur; d = Math.atan2(Math.sin(d), Math.cos(d)); const ang = cur + clamp(d, -p.turn * dt, p.turn * dt); p.vx = Math.cos(ang) * p.speedH; p.vy = Math.sin(ang) * p.speedH; p.facing = p.vx >= 0 ? 1 : -1; } // EDDA Stone Skip: the stone falls under its own gravity and hops off the floor p.bounces more times, then sinks. if (p.skip) { p.vy += p.skipG * dt; if (p.vy > 0 && p.y + p.vy * dt >= CONFIG.groundY - 14) { effects.push({ type: 'edda-fx', asset: 'edda-ripple', x: p.x, y: CONFIG.groundY - 14, facing: p.facing, life: .3, maxLife: .3, size: 110 }); if (p.bounces-- > 0) { p.y = CONFIG.groundY - 14; p.vy = -p.hop; sound('step'); } else { projectiles.splice(i, 1); continue; } } } if (p.cfLand) { p.vy += p.cfG * dt; if (p.vy > 0 && p.y + p.vy * dt >= CONFIG.groundY - 8) { cfLand(p); projectiles.splice(i, 1); continue; } } if (p.gravity) p.vy += p.gravity * dt; p.x += p.vx * dt; p.y += (p.vy || 0) * dt; p.life -= dt; if (p.fx) { const r = fxProjTick(p, dt); if (r === 'remove') { projectiles.splice(i, 1); continue; } if (r === 'skip') continue; } if (p.gravity && p.y >= CONFIG.groundY - 8) { slagSplash(p); projectiles.splice(i, 1); continue; } if (p.landY && p.y >= p.landY) { lanceShatter(p); projectiles.splice(i, 1); continue; } // RHEA: a Gravity Well waits in place and collapses; an Orrery planet laps around its owner. if (p.inert) { if (p.life <= 0) { wellBurst(p); projectiles.splice(i, 1); } continue; } if (p.orbit) { const home = p.owner === 'enemy' ? dummy : hero; p.angle += Math.PI * 2 * dt / p.lap; if (p.angle >= Math.PI * 2) { projectiles.splice(i, 1); continue; } p.x = home.x + p.facing * p.rx * Math.cos(p.angle); p.y = home.y - 90 + p.ry * Math.sin(p.angle); } // ZANNI rings fly out `range` px, then home back to the thrower and vanish in his hand. One hit per ring (`spent`). if (p.boomerang) { p.travel += Math.abs(p.x - oldX); if (!p.returning && p.travel >= p.range) p.returning = true; if (p.returning) { const home = p.owner === 'enemy' ? dummy : hero; p.vx = Math.sign(home.x - p.x || 1) * Math.abs(p.vx); p.facing = Math.sign(p.vx); p.y += (clamp(home.y - 95, home.y - 130, home.y - 60) - p.y) * Math.min(1, dt * 6); if (Math.abs(home.x - p.x) < 34 && p.travel > 80) { projectiles.splice(i, 1); continue; } } } if (p.spent) { if (p.life <= 0) projectiles.splice(i, 1); continue; } if(p.owner==='enemy') { if(Math.min(oldX,p.x)<=hero.x+28&&Math.max(oldX,p.x)>=hero.x-28&&Math.abs(p.y-(hero.y-85))<(p.tall||75)&&window.__game.receiveHit(p.damage,{projectile:true,freeze:p.freeze})){if(p.fx)fxOnHit(p);p.boomerang?(p.spent=true,p.returning=true):(p.orbit||p.pass)?(p.spent=true):p.life=0;} if(p.life<=0||p.x<-100||p.x>W+100)projectiles.splice(i,1);continue; } // Swept x interval keeps a high-speed bolt from tunneling through a target. if (Math.min(oldX, p.x) <= dummy.x + 30 && Math.max(oldX, p.x) >= dummy.x - 30 && Math.abs(p.y - (dummy.y - (F?95:68))) < (p.tall || (F?90:66)) && dummy.state !== 'down' && dummy.state !== 'recover') { if (hitDummy(p.damage||BALANCE.skill1.damage, p.knockback ?? BALANCE.skill1.knockback, p.color || '#94ffde', oldX, p.y, !!p.knockdown, false)) { if (p.fx) fxOnHit(p); if (p.boomerang) { p.spent = true; p.returning = true; } else if (p.orbit || p.pass) p.spent = true; else p.life = 0; } } if (p.life <= 0 || p.x < -100 || p.x > W + 100) projectiles.splice(i, 1); } } function syncPlayerForm() { if(selectedCharacter==='fenr') {const wolf=hero.form==='wolf';manifest=wolf?window.FENR_WOLF_MANIFEST:window.FENR_HUMAN_MANIFEST;metrics=wolf?window.FENR_WOLF_METRICS:window.FENR_HUMAN_METRICS;images.hero=images[wolf?'fenrWolf':'fenrHuman'];Object.assign(cooldownMax,F.balance.cooldowns);} else if(selectedCharacter==='mira') {manifest=window.MIRA_MANIFEST;metrics=window.MIRA_METRICS;images.hero=images.mira;Object.assign(cooldownMax,M.balance.cooldowns);} else if(selectedCharacter==='cora') {manifest=window.CORA_MANIFEST;metrics=window.CORA_METRICS;images.hero=images.cora;Object.assign(cooldownMax,C.balance.cooldowns);} else if(selectedCharacter==='naja') {manifest=window.NAJA_MANIFEST;metrics=window.NAJA_METRICS;images.hero=images.naja;Object.assign(cooldownMax,N.balance.cooldowns);} else if(selectedCharacter==='haldor') {manifest=window.HALDOR_MANIFEST;metrics=window.HALDOR_METRICS;images.hero=images.haldor;Object.assign(cooldownMax,HD.balance.cooldowns);} else if(selectedCharacter==='edda') {manifest=window.EDDA_MANIFEST;metrics=window.EDDA_METRICS;images.hero=images.edda;Object.assign(cooldownMax,ED.balance.cooldowns);} else if(selectedCharacter==='nib') {manifest=window.NIB_MANIFEST;metrics=window.NIB_METRICS;images.hero=images.nib;Object.assign(cooldownMax,NB.balance.cooldowns);} else if(CF[selectedCharacter]) {const fo=cfFormOn(selectedCharacter,'player'),F0=CF[selectedCharacter];manifest=fo?F0.mode.form.manifest:F0.manifest;metrics=fo?F0.mode.form.metrics:F0.metrics;images.hero=images[fo?selectedCharacter+'-form':selectedCharacter];Object.assign(cooldownMax,CF[selectedCharacter].balance.cooldowns);} else if(selectedCharacter==='browo') {manifest=window.BROWO_MANIFEST;metrics=window.BROWO_METRICS;images.hero=images.browo;Object.assign(cooldownMax,BW.balance.cooldowns);} else if(selectedCharacter==='solan') {manifest=window.SOLAN_MANIFEST;metrics=window.SOLAN_METRICS;images.hero=images.solan;Object.assign(cooldownMax,SO.balance.cooldowns);} else if(selectedCharacter==='rhea') {manifest=window.RHEA_MANIFEST;metrics=window.RHEA_METRICS;images.hero=images.rhea;Object.assign(cooldownMax,RH.balance.cooldowns);} else if(selectedCharacter==='isolde') {manifest=window.ISOLDE_MANIFEST;metrics=window.ISOLDE_METRICS;images.hero=images.isolde;Object.assign(cooldownMax,IS.balance.cooldowns);} else if(selectedCharacter==='zanni') {manifest=window.ZANNI_MANIFEST;metrics=window.ZANNI_METRICS;images.hero=images.zanni;Object.assign(cooldownMax,Z.balance.cooldowns);} else {manifest=window.MECHA_MANIFEST||window.SPRITE_MANIFEST;metrics=window.MECHA_METRICS;images.hero=images.arco||images.hero;Object.assign(cooldownMax,{skill1:3,skill2:6,ultimate:18});} hudIdentity=''; } function selectCharacter(id) { if(!ready || match?.mode==='versus' || !playable().includes(id) || (id==='fenr'&&!window.FENR_HUMAN_MANIFEST))return false; selectedCharacter=id;refreshPassives({enemy:sideStyle.enemy});reset();syncPlayerForm();warmCutins();$('#character-select').value=id;updateHud();return true; } function showFenrTransformation(actor) { fenrCutin={t:0,owner:actor===hero?'PLAYER':'RIVAL'};cinematic=.65; startUltimateVoice('fenr', actor); effects.push({type:'fenr-fx',asset:'transform',x:actor.x,y:actor.y-95,facing:actor.facing,life:.8,maxLife:.8,size:230}); } function fenrStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,actorMetrics=actor.form==='wolf'?window.FENR_WOLF_METRICS:window.FENR_HUMAN_METRICS,origin=actorMetrics?.emitters?.[move.name],fy=actor.y+(origin?.y??-(actor.form==='wolf'?120:105)); effects.push({type:'fenr-fx',asset:move.fx,x:move.area?actor.x:actor.x+actor.facing*65,y:fy,facing:actor.facing,life:.32,maxLife:.32,size:move.area?move.reach*2:130}); if(move.projectile){projectiles.push({x:actor.x+actor.facing*(origin?.x??62),y:fy,vx:actor.facing*720,life:1.15,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,asset:'gale'});sound('cast');return;} const dx=(target.x-actor.x)*actor.facing,near=move.area?Math.abs(dx)-22&&dx115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#ffe6ba',actor.x,fy,false,move.type==='attack'); if(connected && move.type==='attack') {F.refund(actor);if(!enemy)for(const n of Object.keys(rechargePulse))rechargePulse[n]=.22;} if(connected)sound('hit'); } function miraStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.MIRA_METRICS?.emitters?.[move.name]; const ox=actor.x+actor.facing*(em?.x??100),oy=actor.y+(em?.y??-125); if(move.projectile){ projectiles.push({x:ox,y:oy,vx:actor.facing*move.speed,life:1.3,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,asset:'mira-star',color:'#ffe27a'}); effects.push({type:'muzzle',x:ox,y:oy,life:.2,maxLife:.2,color:'#fff1a8'});spawnParticles(ox,oy,'#ffe27a',10,140);sound('cast');return; } if(move.type==='attack')effects.push({type:'slash',x:actor.x+actor.facing*62,y:oy+5,life:.22,maxLife:.22,facing:actor.facing,index:move.index,color:move.index===3?'#ffe27a':'#f0d2ff'}); else effects.push({type:'mira-fx',asset:'mira-crash',x:ox,y:oy,facing:actor.facing,life:.3,maxLife:.3,size:150}); const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#ffd6f5',actor.x,oy,false,move.type==='attack'); if(connected&&move.type==='attack') { if(enemy)M.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.type==='skill2'?'heavy':'hit'); } function coraStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.CORA_METRICS?.emitters?.[move.name]; const ox=actor.x+actor.facing*(em?.x??95),oy=actor.y+(em?.y??-140),hy=clamp(oy,actor.y-165,actor.y-60); if(move.projectile){ // The straight feather flies a little faster, so the fan lands as a quick three-hit burst instead of one stacked hit. move.feathers.forEach((damage,i)=>{const angle=(i-1)*move.spread,speed=move.speed*(1-Math.abs(i-1)*.06);projectiles.push({x:ox,y:oy,vx:actor.facing*speed*Math.cos(angle),vy:speed*Math.sin(angle),life:1,facing:actor.facing,owner:enemy?'enemy':'player',damage,knockback:move.knockback,asset:'cora-feather',size:74,color:'#c9a2ff'});}); effects.push({type:'muzzle',x:ox,y:oy,life:.18,maxLife:.18,color:'#e0c8ff'});spawnParticles(ox,oy,'#b58cff',10,140);sound('cast');return; } if(move.type==='attack')effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.22,maxLife:.22,facing:actor.facing,index:move.index,color:move.index===3?'#ffc86b':'#dcc6ff'}); else {effects.push({type:'cora-fx',asset:'cora-gust',x:actor.x+actor.facing*140,y:actor.y-100,facing:actor.facing,life:.34,maxLife:.34,size:170});spawnParticles(actor.x+actor.facing*120,actor.y-100,'#3a2d58',12,220);} const dx=(target.x-actor.x)*actor.facing,near=move.area?dx>-30&&dx-22&&dx115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.area?'#e8d8ff':'#e2ccff',actor.x,hy,false,move.type==='attack'); if(connected&&move.type==='attack') { if(enemy)C.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.type==='skill2'?'heavy':'hit'); } function najaStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NAJA_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-100),actor.y-150,actor.y-50); if(move.projectile){ // Sand Fang runs along the floor from her palm slam: any small jump clears it. const x=actor.x+actor.facing*(em?.x??90); projectiles.push({x,y:actor.y-24,vx:actor.facing*move.speed,life:1.1,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'naja-sandwave',size:130,color:'#f2c86b'}); dust(x,10);spawnParticles(x,actor.y-14,'#e8c27a',10,160);sound('cast');return; } // The urumi cracks out to its measured tip; the cyclone spins all the way around her. if(move.type==='attack')effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+25,life:.2,maxLife:.2,index:move.index,color:move.index===3?'#ffe7a8':'#f2d9a0'}); else {effects.push({type:'naja-fx',asset:'naja-cyclone',x:actor.x,y:actor.y-72,facing:actor.facing,life:.36,maxLife:.36,size:270});spawnParticles(actor.x,actor.y-40,'#e3bf78',16,260,'dust');} const dx=(target.x-actor.x)*actor.facing,near=move.around?Math.abs(dx)-22&&dx115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.around?'#ffe7b0':'#f7dca0',actor.x,hy,false,move.type==='attack'); if(connected&&move.type==='attack') { if(enemy)N.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.type==='skill2'?'heavy':'hit'); } function haldorStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.HALDOR_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-90),actor.y-150,actor.y-40); if(move.projectile){ // Slag Shot: lobbed at where the rival stands now (180-560 px ahead), landing after a fixed flight time. const L=HD.slag,x0=actor.x+actor.facing*(em?.x??70),y0=clamp(actor.y+(em?.y??-120),actor.y-170,actor.y-60); const ahead=(target.x-actor.x)*actor.facing,land=actor.x+actor.facing*clamp(ahead,L.minRange,L.maxRange); const g=2*(CONFIG.groundY-8-y0+L.rise*L.flight)/(L.flight*L.flight); projectiles.push({x:x0,y:y0,vx:(land-x0)/L.flight,vy:-L.rise,gravity:g,life:L.flight+.4,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'haldor-slag',size:64,color:'#ffae52',landX:land,landAt:time+L.flight,splash:L.splash}); effects.push({type:'muzzle',x:x0,y:y0,life:.2,maxLife:.2,color:'#ffb35c'});spawnParticles(x0,y0,'#ff9a3c',10,150);sound('cast');return; } if(move.type==='attack'){ effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:move.index===3?'#ffd28a':'#ffb05e'}); // The overhead anvil smash lands on the floor: dust and a small shake even on a miss. if(move.index===3){dust(actor.x+actor.facing*(em?.x??110),14);trauma=Math.min(1,trauma+.18);} } else {effects.push({type:'haldor-fx',asset:'haldor-steam',x:actor.x-actor.facing*70,y:actor.y-80,facing:actor.facing,life:.4,maxLife:.4,size:190});spawnParticles(actor.x+actor.facing*70,actor.y-90,'#f3ecdf',12,220,'dust');} const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.type==='skill2'?'#fff1dc':'#ffc27a',actor.x,hy,false,move.type==='attack'); if(connected&&move.type==='attack') { if(enemy)HD.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.type==='skill2'||move.index===3?'heavy':'hit'); } function zanniStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.ZANNI_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-100),actor.y-180,actor.y-50); if(move.projectile){ // Ring Toss: a bladed ring at hand height flies out ZANNI.ring.range px and comes back to his hand. const x=actor.x+actor.facing*(em?.x??90),y=clamp(actor.y+(em?.y??-95),actor.y-130,actor.y-70); projectiles.push({x,y,vx:actor.facing*move.speed,life:2.2,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'zanni-ring',size:70,color:'#e4f36a',boomerang:true,range:Z.ring.range,travel:0,spin:18}); effects.push({type:'muzzle',x,y,life:.2,maxLife:.2,color:'#e4f36a'});spawnParticles(x,y,'#e8d27a',10,150);sound('cast');return; } // The scissor lattice telescopes to its measured tip; Spring Snatch reaches 200 px and reels the rival in. if(move.type==='attack')effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+20,life:.18,maxLife:.18,index:move.index,color:move.index===3?'#f4f7a0':'#e4f36a'}); else effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach,life:.26,maxLife:.26,index:3,color:'#f2d46a'}); const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.type==='skill2'?'#f6f3c8':'#e9f27a',actor.x,hy,false,move.type==='attack'); if(connected&&move.pull){ // Reel the rival in to `pull` px in front of ZANNI (never pushes a closer rival away). if(dx>move.pull){target.x=clamp(actor.x+actor.facing*move.pull,70,W-70);target.vx=0;} effects.push({type:'zanni-fx',asset:'zanni-snatch',x:target.x,y:target.y-90,facing:actor.facing,life:.32,maxLife:.32,size:150}); } if(connected&&move.type==='attack') { if(enemy)Z.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.type==='skill2'||move.index===3?'heavy':'hit'); } function isoldeStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.ISOLDE_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40); if(move.projectile){ // Sky Piercer: an ice bolt from the lance point on a rising line (IS.piercer.rise): anti-air, close ground poke. const P=IS.piercer,x=actor.x+actor.facing*Math.round((em?.x??140)*.8),y=actor.y-P.y; projectiles.push({x,y,vx:actor.facing*P.speed,vy:-P.speed*P.rise,life:1.1,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'isolde-piercer',size:96,color:'#bfe6ff'}); effects.push({type:'muzzle',x,y,life:.2,maxLife:.2,color:'#dff3ff'});spawnParticles(x,y,'#dff3ff',10,160);sound('cast');return; } if(move.type==='attack')effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+25,life:.18,maxLife:.18,index:move.index,color:move.index===3?'#eaf7ff':'#bfe6ff'}); else effects.push({type:'isolde-fx',asset:'isolde-frost',x:actor.x-actor.facing*60,y:actor.y-70,facing:actor.facing,life:.4,maxLife:.4,size:190}); const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.type==='skill2'?'#eaf7ff':'#bfe6ff',actor.x,hy,false,move.type==='attack'); if(connected&&move.type==='attack') { if(enemy)IS.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.type==='skill2'||move.index===3?'heavy':'hit'); } function rheaStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.RHEA_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-95),actor.y-180,actor.y-40); if(move.projectile){ // Planet Drift: a slow little planet at chest height that crosses the arena. const x=actor.x+actor.facing*(em?.x??80),y=actor.y-95; projectiles.push({x,y,vx:actor.facing*move.speed,life:RH.drift.life,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'rhea-drift',size:78,color:'#f2c77a',spin:3}); effects.push({type:'muzzle',x,y,life:.2,maxLife:.2,color:'#f2c77a'});spawnParticles(x,y,'#f2c77a',10,150);sound('cast');return; } if(move.well){ // Gravity Well: a vortex opens RH.well.at px ahead and collapses after RH.well.fuse s. const W0=RH.well,x=clamp(actor.x+actor.facing*W0.at,40,W-40); projectiles.push({x,y:CONFIG.groundY-80,vx:0,life:W0.fuse,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'rhea-well',size:170,color:'#e8b0c0',inert:true,radius:W0.radius,spin:-6}); sound('cast');return; } effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+20,life:.18,maxLife:.18,index:move.index,color:move.index===3?'#f7e3b0':'#f2c77a'}); const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#f2c77a',actor.x,hy,false,true); if(connected) { if(enemy)RH.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.index===3?'heavy':'hit'); } function solanStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.SOLAN_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40); if(move.projectile){ // Solar Crescent: a golden crescent wave from the blade edge at chest height, SO.crescent.range px long. const x=actor.x+actor.facing*Math.round((em?.x??110)*.7),y=actor.y-95; projectiles.push({x,y,vx:actor.facing*move.speed,life:move.range/move.speed,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'solan-crescent',size:150,color:'#ffd36a'}); spawnParticles(x,y,'#ffd36a',10,160);sound('cast');return; } let hitX=null; if(move.leap){ // Leonine Leap lands: the blade hits the floor SO.leap.ahead px in front; everything within the radius is hit. hitX=actor.x+actor.facing*SO.leap.ahead; effects.push({type:'solan-fx',asset:'solan-impact',x:hitX,y:CONFIG.groundY-40,facing:actor.facing,life:.4,maxLife:.4,size:220}); dust(hitX,18);spawnParticles(hitX,CONFIG.groundY-10,'#e8c07a',18,300,'dust');trauma=Math.min(1,trauma+.3); } else effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:move.index===3?'#ffe7a0':'#ffd36a'}); if(move.type==='attack'&&move.index===3){dust(actor.x+actor.facing*(em?.x??110),12);trauma=Math.min(1,trauma+.15);} const dx=(target.x-actor.x)*actor.facing; const inside=hitX!==null?Math.abs(target.x-hitX)CONFIG.groundY-130:dx>-22&&dx0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.leap||move.index===3?'heavy':'hit'); } function browoStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.BROWO_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40); const raging=bwx.rage[enemy?'enemy':'player']>0,mul=raging?BW.rage.damageMul:1,dmg=Math.round(move.damage*mul); if(move.truck){ // Truk Katering: a delivery truck crosses the whole stage from behind BRO WO and hits once (it keeps going after the hit). const dir=actor.facing; projectiles.push({x:dir>0?-92:W+92,y:CONFIG.groundY-122,vx:dir*BW.truck.speed,life:(W+210)/BW.truck.speed,facing:dir,owner:enemy?'enemy':'player',damage:Math.round(move.damage*mul),knockback:move.knockback,asset:'browo-truck',size:BW.truck.size,color:'#ffb060',tall:BW.truck.tall,pass:true,knockdown:true}); trauma=Math.min(1,trauma+.35);sound('heavy');return; } if(move.fire){ // Hutan Terbakar: palms catch fire in a row ahead of BRO WO (updateBrowo does the burning). for(let i=0;iCONFIG.groundY-150; } else { effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:raging?'#ff9a5a':move.index===3?'#ffe7a0':'#f0dba5'}); if(move.type==='attack'&&move.index===3){dust(actor.x+actor.facing*(em?.x??95),12);trauma=Math.min(1,trauma+.15);} const dx=(target.x-actor.x)*actor.facing; inside=dx>-22&&dx0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.tree||move.index===3?'heavy':'hit'); } function nibStrike(actor,move) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NIB_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-80),actor.y-170,actor.y-40); if(move.projectile){ // Express Letter: a sealed letter thrown flat at chest height; the fastest projectile, NB.letter.range px long. const x=actor.x+actor.facing*Math.round((em?.x??80)*.8),y=actor.y-90; projectiles.push({x,y,vx:actor.facing*move.speed,life:move.range/move.speed,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'nib-letter',size:70,color:'#f4efe2'}); spawnParticles(x,y,'#f4efe2',8,150);sound('cast');return; } effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+15,life:.14,maxLife:.14,index:move.index||3,color:move.slip?'#bfe8ff':'#8fd4ff'}); const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.slip?'#bfe8ff':'#8fd4ff',actor.x,hy,false,true); if(connected&&move.slip){ // Rooftop Slip: slip past the rival and turn around to face them (the dash stops there). effects.push({type:'nib-fx',asset:'nib-slip',x:target.x,y:target.y-80,facing:actor.facing,life:.3,maxLife:.3,size:150}); actor.x=clamp(target.x+actor.facing*NB.slip.behind,70,W-70);actor.facing*=-1;actor.vx=0;if(actor.action)actor.action.dash=0; } if(connected&&move.type==='attack') { if(enemy)NB.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.slip||move.index===3?'heavy':'hit'); } function eddaStrike(actor,move) { if(move.guard)return; const enemy=actor===dummy,target=enemy?hero:dummy,em=window.EDDA_METRICS?.emitters?.[move.name]; const hy=clamp(actor.y+(em?.y??-80),actor.y-170,actor.y-30); if(move.projectile){ // Stone Skip: a flat jade stone flicked low; it skips along the floor (ED.stone) and hits once. const x=actor.x+actor.facing*Math.round((em?.x??70)*.8),y=clamp(actor.y+(em?.y??-60),actor.y-60,actor.y-40); projectiles.push({x,y,vx:actor.facing*move.speed,vy:-ED.stone.rise,skip:true,skipG:ED.stone.gravity,bounces:ED.stone.bounces,hop:ED.stone.hop,life:3,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'edda-stone',size:54,color:'#9fe0b8',spin:14}); spawnParticles(x,y,'#9fe0b8',8,150);sound('cast');return; } effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+15,life:.14,maxLife:.14,index:move.index||3,color:'#9fe0b8'}); if(move.index===3)effects.push({type:'edda-fx',asset:'edda-ripple',x:actor.x+actor.facing*(em?.x??100),y:CONFIG.groundY-14,facing:actor.facing,life:.3,maxLife:.3,size:130}); const dx=(target.x-actor.x)*actor.facing; if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#9fe0b8',actor.x,hy,false,true); if(connected&&move.type==='attack') { if(enemy)ED.refund(dummy); else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} } if(connected)sound(move.index===3?'heavy':'hit'); } function startEnemyAction(name,index=1) { if(opponentCharacter==='fenr')return F.start(dummy,name,index); if(KITS[opponentCharacter]){if(!KITS[opponentCharacter].start(dummy,name,index))return false;if(name==='ultimate')startSummon(dummy);return true;} if(dummy.action||dummy.cooldowns[name]>0||['hurt','down','recover'].includes(dummy.state))return false; const stateName=name==='attack'?`attack${index}`:name,row=window.MECHA_MANIFEST.animation.rows[stateName],duration=name==='ultimate'?.5:row.frames/row.fps; if(name!=='attack')dummy.cooldowns[name]={skill1:3,skill2:6,ultimate:18}[name]; dummy.action={type:name,name:stateName,index,t:0,duration,fired:false,hitAt:name==='skill2'?.53:.5};dummy.state=stateName;dummy.stateTime=0; if(name==='ultimate')startSquadron(dummy);return true; } function enemyStrike(a) { if(opponentCharacter==='fenr'){fenrStrike(dummy,a);return;} if(opponentCharacter==='mira'){miraStrike(dummy,a);return;} if(opponentCharacter==='cora'){coraStrike(dummy,a);return;} if(opponentCharacter==='naja'){najaStrike(dummy,a);return;} if(opponentCharacter==='haldor'){haldorStrike(dummy,a);return;} if(opponentCharacter==='zanni'){zanniStrike(dummy,a);return;} if(opponentCharacter==='isolde'){isoldeStrike(dummy,a);return;} if(opponentCharacter==='rhea'){rheaStrike(dummy,a);return;} if(opponentCharacter==='solan'){solanStrike(dummy,a);return;} if(opponentCharacter==='nib'){nibStrike(dummy,a);return;} if(opponentCharacter==='edda'){eddaStrike(dummy,a);return;} if(opponentCharacter==='browo'){browoStrike(dummy,a);return;} if(CF[opponentCharacter]){cfStrike(dummy,CF[opponentCharacter],a);return;} const em=window.MECHA_METRICS.emitters[a.name],x=dummy.x+dummy.facing*em.x,y=dummy.y+em.y,dx=(hero.x-dummy.x)*dummy.facing; if(a.type==='skill1'){projectiles.push({x,y,vx:dummy.facing*920,life:1.6,facing:dummy.facing,owner:'enemy',damage:16});effects.push({type:'muzzle',x,y,life:.25,maxLife:.25});sound('cast');} else if(a.type==='skill2'){effects.push({type:'slam',x,y:CONFIG.groundY-2,life:.8,maxLife:.8});sound('heavy');if(Math.abs(hero.x-x)<220&&Math.abs(hero.y-CONFIG.groundY)<125)window.__game.receiveHit(24);} else if(a.type==='attack'){ effects.push({type:'slash',x:dummy.x+dummy.facing*48,y:y+5,life:.22,maxLife:.22,facing:dummy.facing,index:a.index,color:'#cffbf0'}); const reach=Math.max(...window.MECHA_METRICS.states[a.name].frames.map(f=>f.bounds.right)); if(dx>-24&&dx f.bounds.right)) }; if (name === 'skill1') return { name, type: name, duration, hitAt: .5, projectile: true, speed: 920 }; return { name, type: name, duration, hitAt: name === 'skill2' ? .53 : .5, area: true, reach: 220 }; } // Horizontal distance at which a CPU move connects: projectiles go far, dashes add travel. function cpuReach(m) { return m.projectile ? (m.range || 560) : (m.area ? m.reach : m.reach + 27) + (m.dash ? m.dash * m.duration * .4 : 0); } // Open a chain only from where every link of it still reaches (knockback slides the player back a little). function cpuChainReach(P) { let r = Infinity; for (let i = 1; i <= Math.max(1, P.combo); i++) r = Math.min(r, cpuReach(cpuMove('attack', i))); return Math.max(60, r - 14); } function cpuHitTime(m) { return m.duration * (m.hitAt || .5) + (m.projectile ? Math.max(0, Math.abs(hero.x - dummy.x) - 100) / (m.speed || 800) + (m.swarm?.orbit || 0) : 0); } // True when the hit would land on a vulnerable player: inside the current hurt window, after post-hurt immunity, // or after a skill/ultimate the player is immune during. A fresh engagement never starts on a stunned player: // only the confirmed chain (combo + one ender) may use the stun, so there are no infinite loops. function cpuLands(m, fresh = false) { const t = cpuHitTime(m), a = hero.action; if (fresh && hero.hurtTime > 0) return false; if (a && (a.type.startsWith('skill') || a.type === 'ultimate')) return t >= a.duration - a.t; if (hero.hurtTime > 0) return t < hero.hurtTime - .004; return hero.invuln < t - .03; } // Jump timing that puts the feet above a projectile at the moment of contact; double jump for high shots. function cpuJumpPlan(height) { const need = height + 12, v1 = CONFIG.jumpSpeed, v2 = CONFIG.doubleJumpSpeed, g = CONFIG.gravity, second = .16; for (const double of [false, true]) { let first = -1, last = -1; for (let t = 0; t < 1.2; t += 1 / 120) { const y = !double || t < second ? v1 * t - g * t * t / 2 : (v1 * second - g * second * second / 2) + v2 * (t - second) - g * (t - second) ** 2 / 2; if (y < 0) break; if (y >= need) { if (first < 0) first = t; last = t; } } if (first >= 0) return { double, at: (first + last) / 2 }; } return null; } function cpuJump(double) { dummy.vy = -CONFIG.jumpSpeed; dummy.y -= 1; dummy.jumps = 1; dummy.vx = 0; dummy.state = 'jump'; dummy.stateTime = 0; cpu.doubleAt = double ? time + .16 : 0; } function cpuStart(name, index = 1) { if (!startEnemyAction(name, index)) return false; dummy.vx = 0; cpu.plan = null; if (name === 'ultimate' && opponentCharacter === 'fenr') showFenrTransformation(dummy); return true; } // Fastest move that reaches the player now (melee first, then area skill), or null. // The player walking in or pressing buttons: poke from the full reach of attack1 to win the exchange. function cpuPoking() { const toward = Math.sign(dummy.x - hero.x); return hero.vx * toward > 40 || !!hero.action; } function cpuPick(P, dist, allowSkill) { const options = [['attack', 1], ...(allowSkill ? [['skill2', 1], ['skill1', 1]] : [])]; for (const [name, index] of options) { if (name !== 'attack' && dummy.cooldowns[name] > 0) continue; const m = cpuMove(name, index); if (m.guard && !(hero.action?.type === 'attack' && !hero.action.fired)) continue; const walkIn = Math.max(0, hero.vx * Math.sign(dummy.x - hero.x)) * cpuHitTime(m) * P.evade; if (m.projectile ? dist < 180 : dist > (name === 'attack' ? (cpuPoking() ? cpuReach(m) - 6 + walkIn : cpuChainReach(P)) : cpuReach(m) + walkIn)) continue; return { name, index, m }; } return null; } function cpuFollowUp(a, P) { const dist = Math.abs(hero.x - dummy.x), connected = hero.hurtTime > 0; if (a.type !== 'attack') return; if (a.index < P.combo) { const m = cpuMove('attack', a.index + 1), reach = cpuReach(m) - 8; // Chain only on a confirmed hit (or by mistake on lower levels); a blind chain would hit immunity. // Knockback slides the player back, so step in first while the stun still covers the next hit. if (connected && cpuLands(m)) { if (dist <= reach) { startEnemyAction('attack', a.index + 1); return; } if ((dist - reach) / (420 * P.speed) + cpuHitTime(m) < hero.hurtTime - .02) { cpu.plan = { kind: 'chain', index: a.index + 1 }; dummy.aiThink = 0; return; } } else if (!connected && dist <= reach && random() < P.mistakes) { startEnemyAction('attack', a.index + 1); return; } } if (connected && random() < P.ender) for (const name of ['skill2', 'skill1']) { if (dummy.cooldowns[name] > 0) continue; const m = cpuMove(name); if (m.guard) continue; if ((m.projectile || dist <= cpuReach(m)) && cpuLands(m) && cpuStart(name)) return; } } function updateFenrAI(dt) { const P = cpuProfile(); dummy.aiTime += dt; dummy.aiThink = Math.max(0, dummy.aiThink - dt); if (['hurt', 'down', 'recover'].includes(dummy.state)) { dummy.action = null; cpu.plan = null; dummy.aiThink = Math.max(dummy.aiThink, P.reaction + P.wake); return; } if (dummy.action) { const a = dummy.action; a.t += dt; dummy.state = a.name; if (a.dash && a.t > a.duration * .2 && a.t < a.duration * .6) dummy.vx = dummy.facing * a.dash; if (!a.fired && a.type !== 'ultimate' && a.t >= a.duration * a.hitAt) { a.fired = true; strikeAs(a.type === 'attack' ? 'basic' : null, () => enemyStrike(a)); } if (a.t >= a.duration || (opponentCharacter !== 'fenr' && a.type === 'skill1' && a.fired)) { dummy.action = null; dummy.state = 'idle'; dummy.stateTime = 0; dummy.aiThink = P.recovery; if (hero.hp > 0 && !p2Mode) cpuFollowUp(a, P); } return; } const airborne = dummy.y < CONFIG.groundY || dummy.vy < 0; if (p2Mode) { updateP2(dt); return; } if (!aiEnabled || hero.hp <= 0) { dummy.vx = approach(dummy.vx, 0, dt * 1500); if (!airborne) dummy.state = 'idle'; return; } if (airborne) { if (cpu.doubleAt && time >= cpu.doubleAt && dummy.jumps < 2) { dummy.jumps = 2; dummy.vy = -CONFIG.doubleJumpSpeed; cpu.doubleAt = 0; } dummy.state = 'jump'; return; } const dx = hero.x - dummy.x, dist = Math.abs(dx); dummy.facing = dx >= 0 ? 1 : -1; // What the player is doing, seen with a human-like delay. if (hero.action !== cpu.heroAction) { cpu.heroAction = hero.action; cpu.heroActionAt = time; } if (!hero.grounded !== cpu.airborne) { cpu.airborne = !hero.grounded; cpu.airAt = time; cpu.antiAirRead = false; } const seen = since => time - since >= P.reaction; // 1. Projectiles: jump so the shot passes under the feet at contact (decided once per shot). for (const p of projectiles) { if (p.gravity) { // A lob cannot be jumped: run out of its splash by the nearer side before it lands. if (p.owner === 'enemy' || cpu.decided.has(p)) continue; if (!cpu.seen.has(p)) cpu.seen.set(p, time); if (!seen(cpu.seen.get(p))) continue; cpu.decided.add(p); let dir = Math.sign(dummy.x - p.landX) || -dummy.facing; if ((dir < 0 ? dummy.x - 110 : W - 100 - dummy.x) < p.splash + 20) dir = -dir; if (Math.abs(dummy.x - p.landX) < p.splash + 30 && random() < P.evade) cpu.plan = { kind: 'sidestep', dir, until: p.landAt + .05 }; continue; } if (p.owner === 'enemy' || cpu.decided.has(p) || Math.sign(p.vx) !== Math.sign(dummy.x - p.x)) continue; if (!cpu.seen.has(p)) cpu.seen.set(p, time); if (!seen(cpu.seen.get(p))) continue; const tti = (Math.abs(dummy.x - p.x) - 30) / Math.abs(p.vx); if (tti <= 0) { cpu.decided.add(p); continue; } const plan = cpuJumpPlan(CONFIG.groundY - (p.y + (p.vy || 0) * tti)); if (plan && tti > plan.at + 1 / 60) continue; cpu.decided.add(p); if (plan && random() < P.evade) { cpuJump(plan.double); return; } } // 1b. NAJA's locked sand ripple under the CPU: run out of it by the nearer edge (away from a wall) and keep going // until the cobra has erupted (decided once per strike). A late read (low levels) cannot get out in time. for (const k of serpent?.strikes || []) { if (k.state !== 'locked' || cpu.decided.has(k) || time - k.lockedAt < P.reaction) continue; cpu.decided.add(k); let dir = Math.sign(dummy.x - k.x) || -dummy.facing; if ((dir < 0 ? dummy.x - 110 : W - 100 - dummy.x) < N.serpent.radius + 20) dir = -dir; if (Math.abs(dummy.x - k.x) < N.serpent.radius + 30 && random() < P.evade) cpu.plan = { kind: 'sidestep', dir, until: k.lockedAt + N.serpent.telegraph + .05 }; } // 2. A player move just started: step out of a melee startup, or plan to punish its recovery (once per move). const ha = hero.action; if (ha && cpu.read !== ha && seen(cpu.heroActionAt)) { const reach = ha.type === 'attack' ? attackRange(ha.index) + 27 : ha.type === 'skill2' ? 230 : 0, room = dummy.facing > 0 ? dummy.x - 110 : W - 100 - dummy.x; cpu.read = ha; cpu.respect = room > 90 && random() < P.evade; const toHit = ha.duration * (ha.hitAt || (ha.type === 'skill2' ? .53 : .5)) - ha.t, backOff = (reach + 18 - dist) / (380 * P.speed); if (!ha.fired && dist < reach + 15 && random() < P.evade) { const hop = cpuJumpPlan(122); if (room > 90 && backOff < toHit) cpu.plan = { kind: 'retreat', until: time + Math.min(.3, backOff + .06) }; else if (hop && toHit > hop.at - .02) cpu.plan = { kind: 'hop', at: time + Math.max(0, toHit - hop.at), double: hop.double }; } else if (random() < P.punish) cpu.plan = { kind: 'punish', action: ha }; if (cpu.plan?.kind !== 'punish' && random() < P.punish) cpu.followPunish = ha; else cpu.followPunish = null; } if (cpu.plan?.kind === 'hop' && time >= cpu.plan.at) { const double = cpu.plan.double; cpu.plan = cpu.followPunish ? { kind: 'punish', action: cpu.followPunish } : null; cpuJump(double); return; } if (cpu.plan?.kind === 'retreat' && time >= cpu.plan.until && cpu.followPunish) cpu.plan = { kind: 'punish', action: cpu.followPunish }; // 3. Anti-air: meet a landing jump with a grounded hit (decided once per jump). if (cpu.airborne && !cpu.antiAirRead && seen(cpu.airAt)) { cpu.antiAirRead = true; if (random() < P.antiAir) cpu.plan = { kind: 'antiair' }; } if (cpu.plan?.kind === 'antiair') { const m = cpuMove('attack', 1), t = cpuHitTime(m), landY = hero.y + hero.vy * t + CONFIG.gravity * t * t / 2; if (hero.grounded) cpu.plan = null; else if (dist <= cpuReach(m) && hero.vy > 0 && Math.abs(Math.min(landY, CONFIG.groundY) - dummy.y) < 105 && cpuLands(m, true) && cpuStart('attack', 1)) return; } if (cpu.plan?.kind === 'chain') { // Finish a confirmed chain: close the knockback gap, then strike while the player is still stunned. const m = cpuMove('attack', cpu.plan.index); if (!(hero.hurtTime > 0) || !cpuLands(m)) cpu.plan = null; else if (dist <= cpuReach(m) - 8 && cpuStart('attack', cpu.plan.index)) return; } if (cpu.plan?.kind === 'punish') { const pa = cpu.plan.action, pick = cpuPick(P, dist, true); if (hero.action !== pa) cpu.plan = hero.action || hero.hurtTime > 0 ? null : cpu.plan; // A whiffed attack leaves the player open for its whole recovery; skills/ultimate are immune until they end. const open = !hero.action ? hero.invuln <= 0 : pa.type === 'attack' ? pa.fired && !(dummy.state === 'hurt') : false; if (cpu.plan && pick && (open || !(pa.type === 'attack')) && cpuLands(pick.m, true) && cpuHitTime(pick.m) - Math.max(0, hero.action ? hero.action.duration - hero.action.t : 0) < .15 && cpuStart(pick.name, pick.index)) return; if (cpu.plan && !hero.action && !pick && hero.invuln <= 0 && time - cpu.heroActionAt > 1.2) cpu.plan = null; } // Movement: retreat plan, close in (run when far) or rush a punish, hold just outside a startup, or wait in reach // while the player is protected. const reach = (cpuPoking() ? cpuReach(cpuMove('attack', 1)) - 6 : cpuChainReach(P)) + 8, protectedNow = hero.invuln > 0 || hero.hurtTime > 0 || (ha && (ha.type.startsWith('skill') || ha.type === 'ultimate')); const threat = ha && !ha.fired && (ha.type === 'attack' || ha.type === 'skill2') ? (ha.type === 'attack' ? attackRange(ha.index) + 27 : 230) : 0; let wanted = 0; if (cpu.plan?.kind === 'retreat' && time < cpu.plan.until) wanted = -dummy.facing * 380; else if (cpu.plan?.kind === 'sidestep' && time < cpu.plan.until) wanted = cpu.plan.dir * 420; else { if (cpu.plan?.kind === 'retreat' || cpu.plan?.kind === 'sidestep') cpu.plan = null; const rushing = cpu.plan?.kind === 'punish' || cpu.plan?.kind === 'chain'; const punishWait = cpu.plan?.kind === 'punish' && threat && !ha.fired; // An immune (not stunned) player can still hit: wait just outside their reach, then close in so the hit lands // as the immunity ends. A stunned player is approached so the confirmed chain can continue. const immuneFor = hero.hurtTime > 0 ? 0 : Math.max(hero.invuln, ha && (ha.type.startsWith('skill') || ha.type === 'ultimate') ? ha.duration - ha.t : 0); const closeIn = Math.max(0, dist - reach) / (420 * P.speed) + cpuHitTime(cpuMove('attack', 1)) + .05; const outside = attackRange(1) + 27 + 20, keepOut = (threat && (punishWait || (!rushing && cpu.read === ha && cpu.respect))) || immuneFor > closeIn; const goal = keepOut ? Math.max(reach, threat ? threat + 18 : outside) : reach - (protectedNow && hero.hurtTime > 0 ? 20 : 12); wanted = dist > goal ? dummy.facing * (dist > 300 || (rushing && !punishWait) || immuneFor > 0 ? 420 : 240) : dist < Math.min(55, goal - 20) ? -dummy.facing * 150 : keepOut && dist < goal - 6 ? -dummy.facing * 300 : 0; } const ws = passiveMod(dummy, 'walk'); dummy.vx = approach(dummy.vx, clamp(wanted * P.speed * ws, -520 * ws, 520 * ws), dt * 2200); dummy.walkPhase += Math.abs(dummy.vx) * dt / 150; const next = Math.abs(dummy.vx) > 5 ? (Math.abs(dummy.vx) > 300 ? 'run' : 'walk') : 'idle'; if (next !== dummy.state) { dummy.state = next; dummy.stateTime = 0; } if (dummy.aiThink > 0 || cpu.plan?.kind === 'chain' || cpu.plan?.kind === 'sidestep') return; // 4. Decisions on the think timer. const allowSkill = P.skillEvery === 1 || Math.floor(dummy.aiTime / 2) % P.skillEvery === 0; if (dummy.aiTime > P.ultimateAfter && dummy.form === 'human' && dummy.cooldowns.ultimate <= 0 && cpuStart('ultimate')) { dummy.aiThink = P.reaction; return; } const pick = cpuPick(P, dist, allowSkill); if (pick && random() < P.aggression && (cpuLands(pick.m, true) || (hero.hurtTime <= 0 && random() < P.mistakes))) { if (cpuStart(pick.name, pick.index)) { if (pick.name === 'attack') dummy.aiChain++; dummy.aiThink = P.reaction; return; } } // Not landable yet (player protected or hesitation): check again very soon instead of idling a full beat. dummy.aiThink = pick || dist < 260 ? Math.min(P.reaction, 1 / 60) : P.reaction * .5; } function opponentPose() { const m=CF[opponentCharacter]?(cfFormOn(opponentCharacter,'enemy')?CF[opponentCharacter].mode.form.manifest:CF[opponentCharacter].manifest):opponentCharacter==='arco'?window.MECHA_MANIFEST:opponentCharacter==='mira'?window.MIRA_MANIFEST:opponentCharacter==='cora'?window.CORA_MANIFEST:opponentCharacter==='naja'?window.NAJA_MANIFEST:opponentCharacter==='haldor'?window.HALDOR_MANIFEST:opponentCharacter==='zanni'?window.ZANNI_MANIFEST:opponentCharacter==='isolde'?window.ISOLDE_MANIFEST:opponentCharacter==='rhea'?window.RHEA_MANIFEST:opponentCharacter==='solan'?window.SOLAN_MANIFEST:opponentCharacter==='nib'?window.NIB_MANIFEST:opponentCharacter==='edda'?window.EDDA_MANIFEST:opponentCharacter==='browo'?window.BROWO_MANIFEST:dummy.form==='wolf'?window.FENR_WOLF_MANIFEST:window.FENR_HUMAN_MANIFEST; const name=m?.frame_layout?.rows?.[dummy.state]?dummy.state:'idle',list=m?.frame_layout?.rows?.[name]||[]; let index=0; // Projectile casts that release at 50% show wind-up -> thrust -> full extension, like the player's pose. if(dummy.action?.type==='skill1'&&opponentCharacter!=='fenr')index=Math.min(2,list.length-1,Math.floor(dummy.action.t/(dummy.action.duration*.5)*3)); else if(dummy.action?.loops)index=Math.floor(dummy.action.t/dummy.action.duration*list.length*dummy.action.loops)%list.length; else if(dummy.action)index=Math.floor(dummy.action.t/dummy.action.duration*list.length); else if(name==='walk'||name==='run')index=Math.floor(dummy.walkPhase*list.length)%list.length; else if(name==='down')index=Math.floor(dummy.stateTime/.55*list.length); else if(name==='recover')index=Math.floor(dummy.stateTime/.4*list.length); else index=Math.floor(dummy.stateTime*(m?.animation?.rows?.[name]?.fps||5)); if(m?.animation?.rows?.[name]?.loop)index%=list.length; return {state:name,frame:clamp(index,0,Math.max(0,list.length-1)),manifest:m}; } function updateVisuals(dt) { for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += (p.g ?? (p.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680)) * dt; p.vx *= Math.exp(-dt * (p.drag ?? 3)); if (p.life <= 0) particles.splice(i, 1); } for (let i = effects.length - 1; i >= 0; i--) { effects[i].life -= dt; if (effects[i].life <= 0) effects.splice(i, 1); } Feel.ageNumbers(damageNumbers, dt); if (shadowFoe) shadowFx(dt); } function drawBackground(shakeX, shakeY) { const stage = images.stage; if (stage) { // Cover the viewport while aligning the measured stage floor with the combat floor. // Combat remains a uniform 16:9 projection, so fighters never stretch on tall screens. const backdropScale = Math.max(viewW / W, viewH / H); const w = W * backdropScale, h = H * backdropScale; const x = (viewW - w) / 2 + shakeX * scale, y = viewH * .815 - CONFIG.groundY * backdropScale + shakeY * scale; ctx.drawImage(stage, 0, 0, stage.width, stage.height, x, y, w, h); if (x > 0) ctx.drawImage(stage, 0, 0, 1, stage.height, 0, y, x + 1, h); if (x + w < viewW) ctx.drawImage(stage, stage.width - 1, 0, 1, stage.height, x + w - 1, y, viewW - x - w + 1, h); if (y > 0) ctx.drawImage(stage, 0, 0, stage.width, 1, 0, 0, viewW, y + 1); if (y + h < viewH) ctx.drawImage(stage, 0, stage.height - 1, stage.width, 1, 0, y + h - 1, viewW, viewH - y - h + 1); // Overscan edge strips hide the shake without zooming or cropping the artwork. ctx.drawImage(stage, 0, 0, 1, stage.height, x - 45 * scale, y, 45 * scale + 1, h); ctx.drawImage(stage, stage.width - 1, 0, 1, stage.height, x + w - 1, y, 45 * scale + 1, h); } else { ctx.fillStyle = '#9fd3df'; ctx.fillRect(0, 0, viewW, viewH); } if (cinematic > 0) { ctx.fillStyle = `rgba(9,26,39,${Math.min(.65, cinematic * 1.5)})`; ctx.fillRect(0, 0, viewW, viewH); } } function shadow(x, y, width, opacity = .22) { ctx.fillStyle = `rgba(29,46,45,${opacity})`; ctx.beginPath(); ctx.ellipse(Math.round(x), Math.round(y + 2), width, 10, 0, 0, Math.PI * 2); ctx.fill(); } function heroPose() { const list = frames(hero.state).length ? frames(hero.state) : frames('idle'); const row = manifest?.animation?.rows?.[hero.state] || { fps: 8, loop: true }; const result = { state: hero.state, frame: 0, phase: hero.grounded ? 'grounded' : hero.airTime < .07 ? 'takeoff' : Math.abs(hero.vy) <= 100 ? 'apex' : hero.vy < 0 ? 'rising' : 'falling', rotation: 0, pivot: null, worldPivot: null, scale: Math.max(1, Math.round(manifest?.runtime?.scale || manifest?.runtime_scale || 1)) }; let index = 0; if (hero.state === 'jump') { // The flight arc comes from physics. Hold one calm airborne pose, including at the apex. // Replaying the old takeoff/crouch strip in midair caused a visible body-size pop. index = metrics?.playback?.jump?.airFrame ?? Math.min(2, list.length - 1); } else if (hero.state === 'doublejump') { const playback = metrics?.playback?.doublejump; index = playback?.frame ?? 0; const bounds = metrics?.states?.doublejump?.frames?.[index]?.bounds; result.pivot = playback?.pivot || { x: 0, y: bounds ? (bounds.top + bounds.bottom) / 2 : -75 }; result.worldPivot = metrics?.playback?.jump?.torsoPivot || { x: 0, y: -95 }; // Positive canvas rotation flips with facing, producing a forward somersault in either direction. result.rotation = clamp(1 - hero.doublePose / hero.rollDuration, 0, 1) * Math.PI * 2; result.phase = 'somersault'; } else if (hero.state === 'walk' || hero.state === 'run') index = Math.floor(hero.walkPhase * list.length) % list.length; else if (hero.action?.type === 'skill1') index = Math.min(2,list.length-1,Math.floor(hero.action.t/(hero.action.duration*.5)*3)); else if (hero.action?.loops) index = Math.floor(hero.action.t / hero.action.duration * list.length * hero.action.loops) % list.length; else if (hero.action) index = Math.min(list.length - 1, Math.floor(hero.action.t / hero.action.duration * list.length)); else if (row.loop === false) index = Math.min(list.length - 1, Math.floor(hero.animTime * (row.fps || 8))); else index = Math.floor(hero.animTime * (row.fps || 8)) % list.length; result.frame = clamp(index, 0, Math.max(0, list.length - 1)); return result; } function drawHero() { shadow(hero.x, CONFIG.groundY, Math.max(19, (selectedCharacter === 'mira' ? 63 : selectedCharacter === 'haldor' ? 58 : 52) - (CONFIG.groundY - hero.y) * .07), .20); if (!images.hero || !manifest) return; let list = frames(hero.state); if (!list.length) list = frames('idle'); if (!list.length) return; const pose = heroPose(), index = pose.frame; const r = list[index], cw = manifest.cell?.width || manifest.cell?.w || r.w, ch = manifest.cell?.height || manifest.cell?.h || r.h; const anchorX = manifest.cell?.anchor_x ?? cw / 2, anchorY = manifest.cell?.anchor_y ?? ch - (manifest.cell?.safe_margin_y || 0); const spriteScale = pose.scale * stateK(metrics, hero.state), skin = window.Meta?.skinFilter?.(selectedCharacter); if (hero.blocking) { ctx.save(); ctx.strokeStyle = '#ffd36a'; ctx.lineWidth = 5; ctx.globalAlpha = .75; ctx.beginPath(); ctx.arc(hero.x + hero.facing * 20, hero.y - 90, 85, hero.facing > 0 ? -1.1 : Math.PI - 1.1, hero.facing > 0 ? 1.1 : Math.PI + 1.1); ctx.stroke(); ctx.restore(); } if (hero.shield > 0) { ctx.save(); ctx.strokeStyle = '#9fe8ff'; ctx.globalAlpha = Math.min(1, hero.shield) * .8; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(hero.x, hero.y - 90, 70, 105, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } ctx.save(); if (skin) ctx.filter = skin; ctx.translate(Math.round(hero.x), Math.round(hero.y)); ctx.scale(hero.facing * spriteScale, spriteScale); ctx.imageSmoothingEnabled = false; if (pose.pivot) { ctx.translate(pose.worldPivot.x, pose.worldPivot.y); ctx.rotate(pose.rotation); ctx.translate(-pose.pivot.x, -pose.pivot.y); } ctx.drawImage(images.hero, r.x, r.y, r.w, r.h, -anchorX, -anchorY, r.w, r.h); if (hero.flash > 0) drawFlash(hero, images.hero, r, -anchorX, -anchorY); ctx.restore(); } function drawDummy() { const pose=opponentPose(),m=pose.manifest,img=images[opponentCharacter==='fenr'?(dummy.form==='wolf'?'fenrWolf':'fenrHuman'):(cfFormOn(opponentCharacter,'enemy')?opponentCharacter+'-form':opponentCharacter)]; shadow(dummy.x,CONFIG.groundY,dummy.state==='down'?65:opponentCharacter==='mira'?61:opponentCharacter==='haldor'?55:43,.20); if(shadowFoe)drawShadowAura(); if(!m||!img)return; const r=m.frame_layout.rows[pose.state][pose.frame]; ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));{const om=CF[opponentCharacter]?(cfFormOn(opponentCharacter,'enemy')?CF[opponentCharacter].mode?.form?.metrics:CF[opponentCharacter].metrics):null,k=stateK(om,pose.state);ctx.scale((dummy.facing||-1)*k,k);}ctx.imageSmoothingEnabled=false; if(shadowFoe&&FILTER_OK)ctx.filter=SHADOW.filter; ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h); if(shadowFoe){if(FILTER_OK)ctx.filter='none';else drawShadowTint(img,r,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y));} if(dummy.flash>0)drawFlash(dummy,img,r,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y));ctx.restore(); if(dummy.action && !dummy.action.fired && dummy.action.type!=='ultimate') { ctx.save();ctx.strokeStyle='#f0b15e';ctx.lineWidth=2;ctx.globalAlpha=.7; ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore(); } } // BRO WO extras: poison aura on a drained rival, rage aura + palm-tree club on BRO WO, and the giant palm. // The giant palm of the ultimate: behind the fighters while it rises and leans back, in front of them as it comes down. function drawBrowoGiant(front) { const gnt = bwx.giant, im = images['fx-browo-sawit']; if (!gnt || !im) return; const U = BW.forest, t = gnt.t, slamning = t >= U.giantRise + U.giantHold; if (slamning !== front) return; const riseK = clamp(t / U.giantRise, 0, 1), eRise = 1 - Math.pow(1 - riseK, 3), holdT = clamp((t - U.giantRise) / U.giantHold, 0, 1), slamT = clamp((t - U.giantRise - U.giantHold) / U.slam, 0, 1); let ang = -.12 * eRise - .22 * holdT; if (slamT > 0) ang = -.34 + (Math.PI / 2 + .34) * slamT * slamT; const after = t - (U.giantRise + U.giantHold + U.slam), alpha = after > 0 ? clamp(1 - after / .5, 0, 1) : 1; if (alpha <= 0) return; const L = gnt.L * eRise, w = im.width / im.height * gnt.L * .9, shake = t > U.giantRise && slamT === 0 ? Math.sin(realTime * 70) * 2 : 0; ctx.save(); ctx.globalAlpha = alpha; ctx.translate(gnt.x + shake, CONFIG.groundY + 8); ctx.rotate(gnt.dir * ang); ctx.drawImage(im, -w / 2, -L, w, L); ctx.restore(); } function drawBrowoExtras() { if (!BW) return; const bubbles = (a, color) => { for (let i = 0; i < 6; i++) { const ph = (realTime * .9 + i * .17) % 1, bx = a.x + Math.sin(realTime * 3 + i * 2.1) * 26 + (i - 3) * 6, by = a.y - 15 - ph * 130; ctx.globalAlpha = (1 - ph) * .85; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(bx, by, 4 + (i % 3) * 2, 0, 7); ctx.fill(); } ctx.globalAlpha = 1; }; for (const key of ['player', 'enemy']) { const a = key === 'player' ? hero : dummy; if (bwx.poison[key] && bwAlive(a)) { const g = ctx.createRadialGradient(a.x, a.y - 75, 10, a.x, a.y - 75, 95); g.addColorStop(0, 'rgba(120,230,60,.36)'); g.addColorStop(1, 'rgba(120,230,60,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y - 75, 95, 0, 7); ctx.fill(); bubbles(a, '#9be84f'); } } const tw = bwx.tornado, ti = images['fx-browo-tornado']; if (tw && ti) { const U = BW.tornado, grow = Math.min(1, tw.age / .5), fade = tw.age > U.life ? Math.max(0, 1 - (tw.age - U.life) / .45) : 1; for (const [sc, off, al] of [[1, 0, .88], [.78, 3, .5]]) { const h = 580 * grow * sc, w = ti.width / ti.height * h * (1 + Math.sin(realTime * 13 + off) * .05); ctx.save(); ctx.globalAlpha = al * fade; ctx.drawImage(ti, Math.round(tw.x - w / 2 + Math.sin(realTime * 21 + off) * 4), CONFIG.groundY + 14 - h, w, h); ctx.restore(); } } const im = images['fx-browo-sawit']; for (const key of ['player', 'enemy']) { if (bwx.rage[key] <= 0) continue; const a = key === 'player' ? hero : dummy; if (!bwAlive(a)) continue; const aura = modeOf(key === 'player' ? selectedCharacter : opponentCharacter)?.aura || '#ff6e28', rgb = [1, 3, 5].map(k => parseInt(aura.slice(k, k + 2), 16)).join(','); const pulse = .75 + Math.sin(realTime * 9) * .25, g = ctx.createRadialGradient(a.x, a.y - 80, 8, a.x, a.y - 80, 125); g.addColorStop(0, `rgba(${rgb},${.42 * pulse})`); g.addColorStop(1, `rgba(${rgb},0)`); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y - 80, 125, 0, 7); ctx.fill(); if (Math.sin(realTime * 40) > .6) spawnParticles(a.x + (random() - .5) * 50, a.y - 40 - random() * 80, '#ff8a3a', 1, 80); } } // Burning palms of Hutan Terbakar, behind the fighters. function drawBrowoFires() { const im = images['fx-browo-burntree']; if (!im || !bwx.fires.length) return; for (const f of bwx.fires) { if (f.t < 0) continue; const life = BW.fire.life, grow = Math.min(1, f.t / .25), fade = f.t > life - .6 ? Math.max(0, (life - f.t) / .6) : 1, h = 250 * (.35 + .65 * grow) * (1 + Math.sin(realTime * 14 + f.x) * .03), w = im.width / im.height * h; ctx.save(); ctx.globalAlpha = .95 * fade; ctx.drawImage(im, Math.round(f.x - w / 2), CONFIG.groundY + 6 - h, w, h); ctx.restore(); } } // Small palms erupt behind both fighters so BRO WO and the rival stay readable. function drawBrowoTrees() { const im = images['fx-browo-sawit']; if (!im) return; for (const e of effects) { if (e.type !== 'browo-tree') continue; const t = 1 - e.life / e.maxLife, k = Math.min(1, t * 7) * (t > .7 ? 1 - (t - .7) / .3 : 1), h = im.height * .62, w = im.width * .62; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.beginPath(); ctx.rect(-w, -h - 10, w * 2, h + 10); ctx.clip(); ctx.drawImage(im, -w / 2, -h * k, w, h); ctx.restore(); } } function drawEffects() { for (const p of projectiles) { if (p.fx && images['fx-' + p.asset]) { drawFxProjectile(p); continue; } if(p.asset && images['fx-'+p.asset]){const w=p.size||84,h=p.size||76;ctx.save();ctx.translate(p.x,p.y);ctx.scale(p.facing,1);if(p.vy)ctx.rotate(Math.atan2(p.vy,Math.abs(p.vx)));if(p.spin)ctx.rotate(realTime*p.spin);ctx.drawImage(images['fx-'+p.asset],-w/2,-h/2,w,h);ctx.restore();continue;} ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.scale(p.facing, 1); const glow = ctx.createLinearGradient(-75, 0, 15, 0); glow.addColorStop(0, '#73f2dd00'); glow.addColorStop(1, '#affff1bb'); ctx.fillStyle = glow; ctx.beginPath(); ctx.moveTo(-80, -5); ctx.lineTo(10, -11); ctx.lineTo(25, 0); ctx.lineTo(10, 11); ctx.lineTo(-80, 5); ctx.fill(); ctx.fillStyle = '#234f55'; ctx.fillRect(-17, -8, 31, 16); ctx.fillStyle = '#97b4ad'; ctx.fillRect(-14, -6, 29, 11); ctx.fillStyle = '#dbe4c7'; ctx.fillRect(-12, -6, 25, 3); ctx.fillStyle = '#98723f'; ctx.fillRect(-18, -9, 6, 18); ctx.fillStyle = '#efcc84'; ctx.fillRect(-17, -8, 3, 16); ctx.fillStyle = '#71d9c8'; ctx.beginPath(); ctx.moveTo(13, -6); ctx.lineTo(23, 0); ctx.lineTo(13, 7); ctx.fill(); ctx.restore(); } for (const e of effects) { const t = 1 - e.life / e.maxLife; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.globalAlpha = Math.min(1, e.life / e.maxLife * 2); ctx.lineCap = 'square'; if((e.type==='fenr-fx'||e.type==='mira-fx'||e.type==='cora-fx'||e.type==='naja-fx'||e.type==='haldor-fx'||e.type==='zanni-fx'||e.type==='isolde-fx'||e.type==='rhea-fx'||e.type==='solan-fx'||e.type==='nib-fx'||e.type==='edda-fx'||e.type==='browo-fx'||e.type==='cf-fx') && images['fx-'+e.asset]){ctx.scale(e.facing||1,1);const size=e.size*(.75+t*.4);ctx.drawImage(images['fx-'+e.asset],-size/2,-size/2,size,size);} // BRO WO's palm erupts from the floor (clipped at ground level), holds, then sinks back as it fades. // EDDA's tortoise spirit keeps one size; it fades in and out and lifts before each stomp. if(e.type==='edda-spirit'&&images['fx-'+e.asset]){ctx.globalAlpha=Math.min(1,t*6,(1-t)*5)*.92;ctx.scale(e.facing||1,1);ctx.drawImage(images['fx-'+e.asset],-e.size/2,-e.size-e.lift,e.size,e.size);} if (e.type === 'slash') { ctx.scale(e.facing, 1); ctx.rotate(e.index === 2 ? -.5 : .15); ctx.strokeStyle = e.color; ctx.lineWidth = 9 * (1 - t) + 2; ctx.beginPath(); ctx.arc(10, 4, 55 + e.index * 10, -1.2 + t * .3, 1.0 + t * .4); ctx.stroke(); ctx.strokeStyle = '#ffffffaa'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(4, 4, 46 + e.index * 10, -.85, .7); ctx.stroke(); } if (e.type === 'whip') { ctx.scale(e.facing, 1); const bend = (e.index === 2 ? 22 : -26) * (1 - t), end = e.tip * (.7 + .3 * Math.min(1, t * 4)); ctx.lineCap = 'round'; for (const [w, c] of [[7 * (1 - t) + 2, e.color], [2, '#ffffffcc']]) { ctx.strokeStyle = c; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(26, 0); ctx.quadraticCurveTo(end * .55, bend, end, e.index === 2 ? 10 : -4); ctx.stroke(); } } if (e.type === 'airpulse') { ctx.strokeStyle = '#d0fff1'; ctx.lineWidth = 5 * (1 - t); ctx.beginPath(); ctx.ellipse(0, 0, 25 + t * 60, 8 + t * 20, 0, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < 5; i++) { ctx.fillStyle = '#7adbcf'; ctx.fillRect(-38 + i * 18, 10 + t * 35, 4, 17 * (1 - t)); } } if (e.type === 'impact' || e.type === 'muzzle') { ctx.rotate(t * .4); ctx.fillStyle = e.color || '#b2ffef'; for (let i = 0; i < 8; i++) { ctx.rotate(Math.PI / 4); ctx.beginPath(); ctx.moveTo(5, -3); ctx.lineTo((e.heavy ? 60 : 40) * (1 - t) + 7, 0); ctx.lineTo(5, 4); ctx.fill(); } ctx.fillStyle = '#fff9e5'; ctx.fillRect(-7, -7, 14, 14); } if (e.type === 'slam') { ctx.strokeStyle = '#ccf0ff'; ctx.lineWidth = 8 * (1 - t) + 1; ctx.beginPath(); ctx.ellipse(0, -2, 35 + t * 240, 10 + t * 35, 0, 0, Math.PI * 2); ctx.stroke(); for (let i = -3; i <= 3; i++) { const height = (95 - Math.abs(i) * 12) * Math.sin(Math.PI * Math.min(t * 1.4, 1)); ctx.fillStyle = i % 2 ? '#76b1c9dd' : '#d7eff2cc'; ctx.beginPath(); ctx.moveTo(i * 48 - 15, 0); ctx.lineTo(i * 48 - 8, -height); ctx.lineTo(i * 48 + 16, -height * .6); ctx.lineTo(i * 48 + 21, 0); ctx.closePath(); ctx.fill(); } } if (e.type === 'charge' || e.type === 'ultimate-ring') { ctx.strokeStyle = '#e7bb79'; ctx.lineWidth = 3; const radius = e.type === 'charge' ? 100 - t * 65 : 70 + t * 150; ctx.rotate(realTime * 2); ctx.strokeRect(-radius * .7, -radius * .7, radius * 1.4, radius * 1.4); ctx.rotate(Math.PI / 4); ctx.strokeRect(-radius * .6, -radius * .6, radius * 1.2, radius * 1.2); ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.stroke(); } if (e.type === 'cf-wrap') { // rantai membelit: tiga elips emas berputar di sekeliling lawan (mengikuti posisinya) const t = e.who === 'hero' ? hero : dummy; ctx.translate(t.x - e.x, t.y - 85 - e.y); ctx.globalAlpha = Math.min(1, e.life / e.maxLife * 3); ctx.lineCap = 'round'; for (let j = 0; j < 3; j++) for (const [col, wd] of [['#3a2406', 8 - j], [e.color, 4 - j * .6]]) { ctx.strokeStyle = col; ctx.lineWidth = wd; ctx.beginPath(); ctx.ellipse(0, (j - 1) * 36, 54 - j * 4, 14, realTime * 5 * (j % 2 ? -1 : 1) + j, 0, 5.2); ctx.stroke(); } } if (e.type === 'cf-ring') { const rr = e.r * (.25 + .85 * t); ctx.globalAlpha = (1 - t) * .9; ctx.strokeStyle = e.color; ctx.lineWidth = 9 * (1 - t) + 1.5; ctx.beginPath(); ctx.arc(0, 0, rr, 0, 7); ctx.stroke(); ctx.globalAlpha = (1 - t) * .7; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2.5 * (1 - t) + .5; ctx.beginPath(); ctx.arc(0, 0, rr * .82, 0, 7); ctx.stroke(); } if (e.type === 'cf-shock') { // gelombang kejut di lantai: dua cincin elips melebar + cahaya di tengah for (let j = 0; j < 2; j++) { const tt = clamp(t * (1.15 - j * .2) - j * .06, 0, 1), rr = e.r * (.2 + 1.15 * tt); ctx.globalAlpha = (1 - tt) * (j ? .6 : .95); ctx.strokeStyle = j ? '#fff' : e.color; ctx.lineWidth = (j ? 4 : 11) * (1 - tt) + 1.5; ctx.beginPath(); ctx.ellipse(0, 4, rr, rr * .26, 0, 0, 7); ctx.stroke(); } ctx.globalAlpha = (1 - t) * (1 - t) * .55; const gl = ctx.createRadialGradient(0, -4, 2, 0, -4, e.r * .8); gl.addColorStop(0, '#fff'); gl.addColorStop(.4, e.color); gl.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = gl; ctx.beginPath(); ctx.ellipse(0, -4, e.r * .8, e.r * .26, 0, 0, 7); ctx.fill(); } if (e.type === 'drone-laser') { const dx = e.endX - e.x, dy = e.endY - e.y, length = Math.hypot(dx, dy); ctx.rotate(Math.atan2(dy, dx)); ctx.lineCap = 'round'; const strength = Math.min(1, (1 - t) * 3), width = 9 * strength; ctx.strokeStyle = '#50eedb35'; ctx.lineWidth = width * 3.2; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(length,0); ctx.stroke(); ctx.strokeStyle = '#f9ce78d9'; ctx.lineWidth = width * 1.5; ctx.stroke(); ctx.strokeStyle = '#fffbe7'; ctx.lineWidth = Math.max(1.4, width * .4); ctx.stroke(); ctx.fillStyle = '#ffffe6'; ctx.beginPath(); ctx.arc(0,0,width*.85,0,Math.PI*2); ctx.fill(); } ctx.restore(); } for (const p of particles) { ctx.save(); ctx.globalAlpha=clamp(p.life/(p.fade?p.maxLife:.3),0,1)*(p.alpha??1); ctx.fillStyle=p.color; const size=Math.max(1,Math.round(p.size)); if(p.kind==='run-smoke') { const age=1-p.life/p.maxLife, radius=p.size+age*13, x=Math.round(p.x), y=Math.round(p.y); ctx.translate(x,y); ctx.scale(1,.68); const fog=ctx.createRadialGradient(0,0,0,0,0,radius); fog.addColorStop(0,'#edf0e0eb'); fog.addColorStop(.45,'#cbd2c2cc'); fog.addColorStop(1,'#b4c0ac00'); ctx.globalAlpha*=.9; ctx.fillStyle=fog; ctx.beginPath(); ctx.arc(0,0,radius,0,Math.PI*2); ctx.fill(); } else if(p.fade) fxDrawPart(p); else if(p.kind==='dust') {ctx.globalAlpha*=.35;ctx.fillRect(Math.round(p.x),Math.round(p.y),size*3,size*2);} else ctx.fillRect(Math.round(p.x),Math.round(p.y),size,size); ctx.restore(); } Feel.drawRipples(ctx); Feel.drawNumbers(ctx, damageNumbers); } function drawSquadron(group = squad) { if (!group || !images.drone) return; for (const d of group.drones) { if (!d.visible || d.alpha <= 0) continue; const width = SQUAD.droneWidth, height = images.drone.height / images.drone.width * width; ctx.save(); ctx.translate(Math.round(d.x),Math.round(d.y)); ctx.rotate(d.angle); ctx.scale(group.facing,1); ctx.globalAlpha = d.alpha; const moving = group.t < d.delay + .6 || group.t > SQUAD.departure; ctx.fillStyle = '#83ffe397'; ctx.beginPath(); ctx.moveTo(-width*.38,-5); ctx.lineTo(-width*.5-(moving?38:13),0); ctx.lineTo(-width*.38,5); ctx.fill(); if (!d.fired && group.t > 1.1) { ctx.strokeStyle = '#7af9e2aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(width*.38,0,11+(group.t% .15)*35,0,Math.PI*2); ctx.stroke(); } ctx.imageSmoothingEnabled = false; ctx.drawImage(images.drone,-width/2,-height/2,width,height); ctx.restore(); } } function drawParade(group) { if (!group) return; const img = images['fx-mira-rocket']; for (const r of group.rockets) { if (r.state !== 'flying') continue; ctx.save(); ctx.translate(Math.round(r.x), Math.round(r.y)); ctx.rotate(r.angle); ctx.imageSmoothingEnabled = false; if (img) ctx.drawImage(img, -26, -26, 52, 52); else { ctx.fillStyle = '#c9a8f2'; ctx.fillRect(-14, -5, 22, 10); ctx.fillStyle = '#ffe27a'; ctx.beginPath(); ctx.moveTo(8, -5); ctx.lineTo(16, 0); ctx.lineTo(8, 5); ctx.fill(); } ctx.restore(); } } function drawMurmuration(group) { if (!group) return; const img = images['fx-cora-raven'], U = C.murmuration, tail = (U.ravens - 1) * U.spacing; for (const p of group.passes) { if (p.state !== 'flying') continue; // A faint violet wake marks the height of the pass so the dodge window reads at a glance. const tailX = p.front - group.facing * tail, wake = ctx.createLinearGradient(tailX, 0, p.front, 0); wake.addColorStop(0, '#3b2a6a00'); wake.addColorStop(1, '#3b2a6a5c'); ctx.fillStyle = wake; ctx.fillRect(Math.min(tailX, p.front), p.y - 6, tail, 12); for (let k = 0; k < U.ravens; k++) { const x = p.front - group.facing * k * U.spacing; if (x < -60 || x > W + 60) continue; const y = p.y + (k % 2 ? 16 : -16) + Math.sin(group.t * 9 + k * 1.7 + p.id) * 9, flap = .55 + .45 * Math.abs(Math.cos(group.t * 16 + k * 1.3)), size = 60 - (k % 3) * 6; ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(group.facing, flap); ctx.imageSmoothingEnabled = false; if (img) ctx.drawImage(img, -size / 2, -size / 2, size, size); else { ctx.fillStyle = '#231a33'; ctx.beginPath(); ctx.moveTo(14, 0); ctx.lineTo(-12, -9); ctx.lineTo(-6, 0); ctx.lineTo(-12, 9); ctx.fill(); } ctx.restore(); } } } function drawSerpent(group) { if (!group) return; const ripple = images['fx-naja-ripple'], cobra = images['fx-naja-serpent'], U = N.serpent; for (const k of group.strikes) { if (k.state === 'tracking' || k.state === 'locked') { // The ripple pulses while it follows and turns bright and still once locked: that is the cue to move. const locked = k.state === 'locked', w = U.radius * 2 + 20, pulse = locked ? 1 : .55 + .2 * Math.sin(group.t * 14 + k.id); ctx.save(); ctx.translate(Math.round(k.x + (locked ? Math.sin(realTime * 70) * 2 : 0)), CONFIG.groundY); ctx.globalAlpha = pulse; ctx.imageSmoothingEnabled = false; if (ripple) ctx.drawImage(ripple, -w / 2, -w / 2, w, w); else { ctx.strokeStyle = '#e3bf78'; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(0, 0, U.radius, 12, 0, 0, Math.PI * 2); ctx.stroke(); } if (locked) { ctx.globalAlpha = .5; ctx.strokeStyle = '#fff1c2'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, 0, U.radius * (1 - (group.t - k.lockAt) / U.telegraph * .3), 11, 0, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); } if (k.state !== 'erupting') continue; // Rise fast, hold, then sink back into the sand; clipped at the floor so it grows out of the ground. const u = (group.t - k.at) / U.rise, h = U.height + 40, size = cobra ? h * cobra.width / 496 : h; const out = u < .25 ? 1 - Math.pow(1 - u / .25, 3) : u > .7 ? 1 - Math.pow((u - .7) / .3, 2) : 1; ctx.save(); ctx.beginPath(); ctx.rect(k.x - size, CONFIG.groundY - h - 40, size * 2, h + 50); ctx.clip(); ctx.translate(Math.round(k.x), Math.round(CONFIG.groundY + 12 + (1 - out) * h)); ctx.scale(group.facing, 1); ctx.imageSmoothingEnabled = false; if (cobra) ctx.drawImage(cobra, -size / 2, -size, size, size); else { ctx.fillStyle = '#d9b77a'; ctx.fillRect(-26, -h, 52, h); } ctx.restore(); } } function drawHitboxes() { if (!hitboxes) return; ctx.save(); ctx.lineWidth = 2; ctx.setLineDash([5, 4]); ctx.strokeStyle = '#5affce'; ctx.strokeRect(hero.x - 28, hero.y - 128, 56, 128); ctx.strokeStyle = '#ffe186'; ctx.strokeRect(dummy.x - 30, dummy.y - 165, 60, 165); if (hero.action?.type === 'attack') { const reach = attackRange(hero.action.index); ctx.fillStyle = '#ff925c35'; ctx.strokeStyle = '#ff935d'; const x = hero.facing === 1 ? hero.x : hero.x - reach; ctx.fillRect(x, hero.y - 126, reach, 113); ctx.strokeRect(x, hero.y - 126, reach, 113); } ctx.strokeStyle = '#fff8'; ctx.beginPath(); ctx.moveTo(0, CONFIG.groundY); ctx.lineTo(W, CONFIG.groundY); ctx.stroke(); ctx.restore(); } function draw() { ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#9ed2dc'; ctx.fillRect(0, 0, viewW, viewH); // Pengali gerak (access.js "Kurangi gerakan"): 1 = penuh, 0 = tanpa getaran layar (acak dan sentakan searah pukulan) dan tanpa zoom kamera ultimate. const mo = window.Access?.motion ?? 1, kick = Feel.kickOffset(), shakeX = (Math.sin(realTime * 93) * trauma * trauma * 13 + kick.x) * mo, shakeY = (Math.cos(realTime * 111) * trauma * trauma * 9 + kick.y) * mo; // BRO WO's ultimate dives the camera onto him: the whole picture (stage and fighters) is zoomed around a point that eases // toward the screen centre, clamped so the zoomed stage never leaves an empty edge; the stage is then darkened. // K.O. (feel.js) memakai kamera yang sama: zoom ke yang kalah, dipusatkan penuh. const koCam = koCamera(), camZoom = koCam ? koCam.z : 1 + (bwx.zoom - 1) * mo, camera = camZoom > 1.002 || bwx.dim > .004; if (camera) { const sx = offsetX + (koCam ? koCam.x : bwx.cx) * scale, sy = offsetY + (koCam ? koCam.y : bwx.cy) * scale, z = camZoom, w = koCam ? 1 : clamp((z - 1) / .75, 0, 1); const ax = clamp(sx + (viewW / 2 - sx) * w, viewW - z * (viewW - sx), z * sx), ay = clamp(sy + (viewH * .55 - sy) * w, viewH - z * (viewH - sy), z * sy); ctx.save(); ctx.translate(ax, ay); ctx.scale(z, z); ctx.translate(-sx, -sy); } drawBackground(shakeX, shakeY); if (bwx.dim > .004) { ctx.fillStyle = `rgba(3,8,5,${bwx.dim})`; ctx.fillRect(-viewW, -viewH, viewW * 3, viewH * 3); } ctx.save(); ctx.translate(offsetX + shakeX * scale, offsetY + shakeY * scale); ctx.scale(scale, scale); // Light motes provide life without moving the fixed stage or camera. for (let i = 0; i < 14; i++) { const x = (i * 117 + realTime * (6 + i % 3)) % W, y = 210 + (i * 47) % 330 + Math.sin(realTime + i) * 9; ctx.fillStyle = i % 2 ? '#fff8cb88' : '#f0ffe94d'; ctx.fillRect(Math.round(x), Math.round(y), 2, 2); } drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack(); if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } drawBrowoGiant(true); drawCfFront(); drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); Feel.ko.drawOverlay(ctx, viewW, viewH); updateHud(); } function fighterInfo(id,form) { if(id==='fenr'){const wolf=form==='wolf';return {name:'FENR',cls:wolf?'WEREWOLF':'DEMI-HUMAN',deck:wolf?'FENR · FERAL':'FENR',title:'WOLF RANGER',portrait:'assets/fenr/ui/portrait-'+(wolf?'wolf':'human')+'.webp',names:F.kits[wolf?'wolf':'human'].names,icons:['basic','skill1','skill2','ultimate'].map((s,i)=>'assets/fenr/ui/icon-'+(i===3?'ultimate':(wolf?'wolf':'human')+'-'+s)+'.webp')};} if(id==='cora')return {name:'CORA',cls:'DEMI-HUMAN',deck:'CORA',title:'SKY DANCER',portrait:'assets/cora/ui/portrait.webp',names:C.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/cora/ui/icon-'+s+'.webp')}; if(id==='edda')return {name:'EDDA',cls:'DEMI-HUMAN',deck:'EDDA',title:'SHELL SAGE',portrait:'assets/edda/ui/portrait.webp',names:ED.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/edda/ui/icon-'+s+'.webp')}; if(id==='nib')return {name:'NIB',cls:'DEMI-HUMAN',deck:'NIB',title:'ROOFTOP POST',portrait:'assets/nib/ui/portrait.webp',names:NB.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/nib/ui/icon-'+s+'.webp')}; if(CF[id]){const f=CF[id];return {name:f.name,cls:f.race,deck:f.name,title:f.title,portrait:'assets/'+id+'/ui/portrait.webp',names:f.names,icons:['basic','skill1','skill2','ultimate'].map(n=>'assets/'+id+'/ui/icon-'+n+'.webp')};} if(id==='browo')return {name:'BRO WO',cls:'HUMAN',deck:'BRO WO',title:'PALM RANGER',portrait:'assets/browo/ui/portrait.webp',names:BW.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/browo/ui/icon-'+s+'.webp')}; if(id==='solan')return {name:'SOLAN',cls:'DEMI-HUMAN',deck:'SOLAN',title:'SUNMANE',portrait:'assets/solan/ui/portrait.webp',names:SO.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/solan/ui/icon-'+s+'.webp')}; if(id==='rhea')return {name:'RHEA',cls:'MECHA',deck:'RHEA',title:'LITTLE ORRERY',portrait:'assets/rhea/ui/portrait.webp',names:RH.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/rhea/ui/icon-'+s+'.webp')}; if(id==='isolde')return {name:'ISOLDE',cls:'MECHA',deck:'ISOLDE',title:'WHITE STRIDER',portrait:'assets/isolde/ui/portrait.webp',names:IS.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/isolde/ui/icon-'+s+'.webp')}; if(id==='zanni')return {name:'ZANNI',cls:'MECHA',deck:'ZANNI',title:'CLOCKWORK JESTER',portrait:'assets/zanni/ui/portrait.webp',names:Z.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/zanni/ui/icon-'+s+'.webp')}; if(id==='haldor')return {name:'HALDOR',cls:'MECHA',deck:'HALDOR',title:'WALKING FORGE',portrait:'assets/haldor/ui/portrait.webp',names:HD.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/haldor/ui/icon-'+s+'.webp')}; if(id==='naja')return {name:'NAJA',cls:'DEMI-HUMAN',deck:'NAJA',title:'DUNE COBRA',portrait:'assets/naja/ui/portrait.webp',names:N.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/naja/ui/icon-'+s+'.webp')}; if(id==='mira')return {name:'MIRA',cls:'MECHA',deck:'MIRA',title:'CANDY PILOT',portrait:'assets/mira/ui/portrait.webp',names:M.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/mira/ui/icon-'+s+'.webp')}; return {name:'ARCO',cls:'MECHA',deck:'ARCO',title:'AETHER ARM',portrait:'assets/ui/arco-avatar.webp',names:['IRON CHAIN','AETHER BOLT','SEISMIC DRIVE','HELIOS SQUADRON'],icons:['attack','skill1','skill2','squadron-icon'].map(s=>'assets/ui/'+s+'.webp')}; } // Long fighter names (SON OF SOLO MAN) shrink so they stay on one line above the health bar. function hudFit(el, name) { el.style.fontSize = ''; if (name.length > 10) { const fs = parseFloat(getComputedStyle(el).fontSize); if (fs) el.style.fontSize = (fs * Math.min(1, 10 / name.length) * 1.05) + 'px'; } } function updateFighterIdentity() { if(!F)return; const identity=selectedCharacter+(bwx.rage.player>0&&modeOf(selectedCharacter)?'*mode':'')+':'+hero.form+':'+opponentCharacter+':'+dummy.form+(shadowFoe?'*shadow':''); if(identity!==hudIdentity) { hudIdentity=identity;const me=fighterInfo(selectedCharacter,hero.form),rival=fighterInfo(opponentCharacter,dummy.form); if(shadowFoe){rival.cls=rival.name;rival.name='BAYANGAN';rival.title='SALINAN GELAP';}$('#enemy-portrait').classList.toggle('shadow-foe',shadowFoe);$('.fighter-two').classList.toggle('shadow-foe',shadowFoe); if(bwx.rage.player>0&&modeOf(selectedCharacter)){const M=modeOf(selectedCharacter);me.names=M.names;me.icons=M.icons.map(n=>'assets/'+selectedCharacter+'/ui/icon-'+n+'.webp');} $('#player-name').textContent=me.name;hudFit($('#player-name'),me.name);$('#player-class').textContent=me.cls;$('#deck-character').textContent=me.deck; $('#player-title').textContent=me.title; $('#player-portrait').src=me.portrait;$('#player-portrait').alt='Portrait '+me.name; $('#enemy-portrait').src=rival.portrait;$('#enemy-portrait').alt='Portrait '+rival.name;$('#enemy-class').textContent=rival.cls; $('#enemy-name').textContent=rival.name;hudFit($('#enemy-name'),rival.name);$('#enemy-title').textContent=rival.title; $('.fighter-two').setAttribute('aria-label','Status lawan '+opponentCharacter.toUpperCase()); document.querySelectorAll('.skill-card').forEach((card,i)=>{card.querySelector('.skill-name').textContent=me.names[i];card.querySelector('img').src=me.icons[i];card.setAttribute('aria-label',me.names[i]+', '+['Spasi','I','O','P'][i]);}); } $('#player-status').textContent=selectedCharacter==='fenr'?(hero.form==='wolf'?'FERAL ACTIVE':'RANGER READY'):selectedCharacter==='mira'?'PILOT READY':selectedCharacter==='cora'?'WINGS READY':selectedCharacter==='naja'?'SANDS READY':selectedCharacter==='haldor'?'FURNACE HOT':selectedCharacter==='zanni'?'SHOWTIME':selectedCharacter==='isolde'?'LANCE READY':selectedCharacter==='rhea'?'ORBITS SET':selectedCharacter==='solan'?'PRIDE READY':selectedCharacter==='nib'?'ON THE ROUTE':selectedCharacter==='edda'?'STEADY AS STONE':CF[selectedCharacter]?(bwx.rage.player>0&&modeOf(selectedCharacter)?modeOf(selectedCharacter).label+' '+Math.ceil(bwx.rage.player)+'s':CF[selectedCharacter].status):selectedCharacter==='browo'?(bwx.rage.player>0?'MENGAMUK '+Math.ceil(bwx.rage.player)+'s':'SAFARI READY'):'CORE ACTIVE'; for(const [actor,id] of [[hero,'player-form-timer'],[dummy,'enemy-form-timer']]){const el=$('#'+id),active=actor.form==='wolf';el.hidden=!active;el.textContent=active?'FERAL '+Math.max(0,actor.formTime).toFixed(1)+' s':'';el.style.setProperty?.('--remaining',String(actor.formTime/F.balance.duration));} } // ---- Meter ultimate di HUD (Tahap 2.1) ---- // Satu bar tipis di bawah HP tiap sisi (#ult-player, #ult-enemy). Data yang sama untuk pemain dan CPU/P2: cooldown ultimate // aktor, mode pasca-ultimate dari modeOf()/bwx.rage, bentuk feral FENR dari form/formTime. Bar terisi = cooldown pulih; // SIAP berdenyut; selama mode bar menjadi waktu mode yang menyusut. DOM hanya ditulis saat tampilannya berubah. const ULT_COLOR = { arco: '#67e4dd', fenr: '#e0a060', browo: '#d9b44a' }, ultMeters = {}; function ultMax(side) { if (side === 'player') return window.Access?.cdMax?.('ultimate', cooldownMax.ultimate) ?? cooldownMax.ultimate; // "Tanpa cooldown": bilah ikut batas 5 dtk const id = opponentCharacter; return (id === 'fenr' ? F?.balance?.cooldowns?.ultimate : KITS[id]?.balance?.cooldowns?.ultimate) || BALANCE.ultimate.cooldown; } function ultState(side) { const mine = side === 'player', actor = mine ? hero : dummy, id = mine ? selectedCharacter : opponentCharacter; if (id === 'fenr' && F && (actor.form === 'wolf' || actor.transformPending > 0)) return { mode: true, label: 'FERAL', left: actor.formTime || F.balance.duration, total: F.balance.duration, color: ULT_COLOR.fenr }; const md = bwx.rage[side] > 0 ? modeOf(id) : null; if (md) return { mode: true, label: md.label, left: bwx.rage[side], total: md.duration, color: md.aura, again: (actor.cooldowns.ultimate || 0) <= 0 }; return { mode: false, cd: Math.max(0, actor.cooldowns.ultimate || 0), max: Math.min(ultMax(side), 120), color: CF[id]?.color || ULT_COLOR[id] || '#e6c78f' }; } function updateUltMeter(side) { let m = ultMeters[side]; if (m === undefined) { const root = $('#ult-' + side); m = ultMeters[side] = root ? { root, fill: root.querySelector('.ult-fill'), name: root.querySelector('.ult-name'), val: root.querySelector('.ult-val'), kbd: root.querySelector('.ult-kbd'), key: '', p: -1 } : null; } if (!m) return; const live = side === 'player' || aiEnabled || p2Mode, st = ultState(side); let state, p, name, val, low = false; if (st.mode) { state = 'mode'; p = clamp(st.left / st.total, 0, 1); name = st.label; val = st.left.toFixed(1).replace('.', ',') + ' dtk'; low = st.left <= 2; } else if (st.cd <= 0) { state = 'ready'; p = 1; name = 'ULTIMATE'; val = 'SIAP'; } else { state = 'charging'; p = clamp(1 - st.cd / st.max, 0, 1); name = 'ULTIMATE'; val = Math.ceil(st.cd) + ' dtk'; } const kbd = side === 'player' ? 'P' : '', key = [live, state, name, val, low, st.again, st.color, kbd].join('|'); if (key !== m.key) { m.key = key; m.root.hidden = !live; m.root.dataset.state = state; m.root.classList.toggle('low', low); m.root.classList.toggle('again', !!st.again); m.root.style.setProperty('--ult', st.color); m.name.textContent = name; m.val.textContent = val; m.kbd.textContent = kbd; m.kbd.hidden = !kbd; m.root.setAttribute('aria-label', (side === 'player' ? 'Ultimate pemain: ' : 'Ultimate lawan: ') + (st.mode ? 'mode ' + name + ' aktif' : state === 'ready' ? 'siap' : 'mengisi')); } if (Math.abs(p - m.p) > .002) { m.p = p; m.fill.style.transform = 'scaleX(' + p.toFixed(3) + ')'; } } function updateUltMeters() { if (menuOpen) return; updateUltMeter('player'); updateUltMeter('enemy'); } // ---- Daftar gerakan di menu jeda (Tahap 2.4) ---- // Diisi ulang tiap jeda dari data karakter yang sedang dimainkan (nama skill, mode, jurus toko) dan dari mode dua pemain. const MOVE_KEY_PREF = 'konoha.moveList'; function buildMoveList() { const body = $('#move-list-body'); if (!body) return; const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; const kb = list => { const k = el('span', 'mv-keys'); list.forEach(x => { if (x === '+') k.append(el('span', '', '+')); else k.append(el('kbd', 'mv-key', x)); }); return k; }; // hint satu baris di samping nama (inline) atau di baris kedua (nama skill yang panjang) const row = (list, name, hint, inline) => { const r = el('div', 'mv-row'), t = el('span'); t.append(el('span', '', name)); if (hint) t.append(el('small', inline ? 'inl' : '', hint)); r.append(kb(list), t); return r; }; const sec = (title, sub, cls) => { const s = el('section', 'mv-sec' + (cls ? ' ' + cls : '')), h = el('h3', '', title); if (sub) h.append(el('small', '', sub)); s.append(h); body.append(s); return s; }; body.textContent = ''; const id = selectedCharacter, me = fighterInfo(id, 'human'), base = bwBaseCooldowns(id) || cooldownMax, md = modeOf(id), cd = n => Math.round(base[n]) + ' dtk'; const shop = SHOP_MOVES[window.Meta?.equippedMove?.()]; sec('Kontrol dasar').append(row(['A', 'D'], 'Jalan', 'ketuk 2× = lari', 1), row(['W'], 'Lompat', '2× = lompat ganda', 1), row(['S'], 'Menunduk'), row(['L'], 'Blok', 'ketuk cepat = parry', 1), row(['J'], 'Lempar'), row(['K'], 'Jurus toko', shop ? shop.name : 'belum dipasang', 1)); sec('Serangan dan skill', me.name).append(row(['Spasi'], me.names[0], 'kombo 3 pukulan'), row(['I'], me.names[1], 'skill 1 · cooldown ' + cd('skill1')), row(['O'], me.names[2], 'skill 2 · cooldown ' + cd('skill2')), row(['P'], me.names[3], 'ultimate · cooldown ' + cd('ultimate'))); const prow = ([k, n, fx, desc]) => { const r = row([k], n, fx, 1); if (desc) r.lastChild.append(el('small', 'pas-desc', desc)); return r; }; const mine = passiveRows(id, 'player'); if (mine) sec('Pasif dan gaya', me.name).append(...mine.map(prow)); sec('Gerakan perintah', null, 'cmd').append(row(['↓', '↓', '+', 'Spasi'], 'Uppercut', 'peluncur ke atas'), row(['↓', '→', '+', 'Spasi'], 'Tinju maju', '→ = arah lawan')); if (md) { const ms = sec('Mode ' + md.label, Math.round(md.duration) + ' dtk'); ['Spasi', 'I', 'O', 'P'].forEach((k, i) => ms.append(row([k], md.names[i], i === 3 ? 'sekali pakai, mengakhiri mode' : '', i !== 3))); } const other = sec('Lainnya'); other.append(row(['Esc'], 'Jeda / lanjut'), row(['R'], 'Mulai ulang')); if (document.documentElement.classList.contains('touch')) other.append(el('small', 'mv-note', 'Layar sentuh: UP, →P, K, BLOK, dan LEMPAR ada di sisi kanan.')); if (p2Mode) { const rv = fighterInfo(opponentCharacter, 'human'), p2 = sec('Pemain 2', rv.name); p2.append(row(['←', '→'], 'Jalan'), row(['↑'], 'Lompat'), row(['N'], rv.names[0], 'kombo 3 pukulan'), row(['M'], rv.names[1], 'skill 1'), row([','], rv.names[2], 'skill 2'), row(['.'], rv.names[3], 'ultimate')); const rp = passiveRows(opponentCharacter, 'enemy'); if (rp) sec('Pasif pemain 2', rv.name).append(...rp.map(prow)); } } // Pilihan lipat/buka diingat per perangkat; tanpa pilihan, terbuka bila jendela cukup lebar. Hanya klik pengguna pada ringkasan yang menyimpan. function openMoveList() { buildMoveList(); const d = $('#move-list'); if (!d) return; const saved = prefs.get(MOVE_KEY_PREF, null); d.open = saved === null ? window.innerWidth >= 700 : saved === '1'; } $('#move-list > summary')?.addEventListener('click', () => prefs.set(MOVE_KEY_PREF, $('#move-list').open ? '0' : '1')); function updateHud() { updateFighterIdentity(); updateMatchHud(); updateUltMeters(); if(systemAnnouncer){const a=systemAnnouncer.snapshot();canvas.dataset.announcerVoice=a.voice;canvas.dataset.announcerCue=a.cue;canvas.dataset.announcerLastCue=a.lastCue;canvas.dataset.announcerActive=String(a.active);canvas.dataset.announcerPaused=String(a.paused);canvas.dataset.announcerTime=String(a.time);canvas.dataset.announcerStarts=String(a.starts);canvas.dataset.announcerError=a.error;} canvas.dataset.character=selectedCharacter;canvas.dataset.playerForm=hero.form||'human';canvas.dataset.opponentForm=dummy.form||'human';canvas.dataset.opponentState=dummy.state;canvas.dataset.aiEnabled=String(aiEnabled);canvas.dataset.opponentFrame=String(opponentPose().frame); canvas.dataset.ready = String(ready); canvas.dataset.audioState = audio?.state || 'uninitialized'; canvas.dataset.audioSources = String(audioSources); canvas.dataset.voiceName = voiceName; canvas.dataset.voiceOwner = voiceActor === dummy ? 'enemy' : 'player'; canvas.dataset.fenrVoiceReadyState = String(voiceBank.fenr?.readyState || 0); canvas.dataset.voiceActive = String(voiceActive); canvas.dataset.voicePaused = String(ultimateVoice?.paused ?? true); canvas.dataset.voiceTime = String(ultimateVoice?.currentTime || 0); canvas.dataset.voiceDuration = String(Number.isFinite(ultimateVoice?.duration) ? ultimateVoice.duration : 0); canvas.dataset.voiceStarts = String(voiceStarts); canvas.dataset.voiceReadyState = String(ultimateVoice?.readyState || 0); canvas.dataset.voiceError = voiceError; canvas.dataset.droneCount = String(squad?.drones.filter(d => d.visible).length || 0); canvas.dataset.droneShots = String(squad?.shots || 0); canvas.dataset.ultimatePhase = squad?.phase || 'none'; const currentPose = heroPose(); canvas.dataset.poseState = currentPose.state; canvas.dataset.poseFrame = String(currentPose.frame); canvas.dataset.posePhase = currentPose.phase; canvas.dataset.poseRotation = currentPose.rotation.toFixed(6); canvas.dataset.poseScale = String(currentPose.scale); canvas.dataset.posePivot = currentPose.pivot ? `${currentPose.pivot.x},${currentPose.pivot.y}` : ''; canvas.dataset.poseWorldPivot = currentPose.worldPivot ? `${currentPose.worldPivot.x},${currentPose.worldPivot.y}` : ''; const h=hpLayers(hero.hp), dh=hpLayers(dummyHpShown()); $('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`; $('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`; $('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING'); $('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE`; Feel.comboHud({ hits: comboHits, timer: comboTimer, timerMax: 2.3, show: comboHits > 0 && (match?.mode !== 'versus' || match.phase === 'fight') }); window.Meta?.hud?.({ move: SHOP_MOVES[window.Meta?.equippedMove?.()], cd: hero.shopCd || 0, sp: hero.spCd || 0 }); $('#total-damage').innerHTML = `${totalDamage} DMG`; $('#best-combo').innerHTML = `${bestCombo} HITS`; $('#announcement').classList.toggle('visible', announceTimer > 0); for (const card of document.querySelectorAll('.skill-card')) { const name = card.dataset.action, cd = hero.cooldowns[name] || 0; card.classList.toggle('active', name === 'attack' ? hero.action?.type === 'attack' : name === 'ultimate' ? (!!squad || !!parade || !!flock || !!serpent || !!quake || !!finale || !!skyfall || !!orrery || !!sunroar || !!delivery || !!tortoise || (selectedCharacter==='fenr'&&(hero.form==='wolf'||hero.transformPending>0))) : hero.action?.type === name); card.classList.toggle('on-cooldown', cd > 0); card.setAttribute('aria-disabled', String(cd > 0 || (name==='ultimate'&&selectedCharacter==='fenr'&&(hero.form==='wolf'||hero.transformPending>0)))); card.classList.toggle('recharged',(rechargePulse[name]||0)>0); const wipe = card.querySelector('.cooldown-wipe'); if (wipe) { const angle = cd > 0 ? (1 - cd / (name === 'ultimate' ? ultMax('player') : cooldownMax[name])) * 360 : 360; wipe.style.background = `conic-gradient(from 0deg, transparent 0deg ${angle.toFixed(2)}deg, rgba(0,0,0,.68) ${angle.toFixed(2)}deg 360deg)`; } } // One banner at a time: FENR transformation, then ARCO, then a MIRA/CORA/NAJA/HALDOR summon still inside its cut-in window. const cutin=$('#ultimate-cutin'),live=g=>g&&g.t{cutinArt.style.visibility='';};}cutin.querySelector('.cutin-kicker').textContent={fenr:'FENR / '+cutinOwner,mira:'MIRA / '+cutinOwner,cora:'CORA / '+cutinOwner,naja:'NAJA / '+cutinOwner,haldor:'HALDOR / '+cutinOwner,zanni:'ZANNI / '+cutinOwner,isolde:'ISOLDE / '+cutinOwner,rhea:'RHEA / '+cutinOwner,solan:'SOLAN / '+cutinOwner,browo:'BRO WO / '+cutinOwner,browofire:'BRO WO / '+cutinOwner,nib:'NIB / '+cutinOwner,edda:'EDDA / '+cutinOwner,arco:'ARCO / AETHER COMMAND'}[cutinKey];cutin.querySelector('strong').innerHTML={fenr:'FERAL
AWAKENING',mira:'ROCKET
PARADE',cora:'NIGHT
MURMURATION',naja:'DUNE
SERPENT',haldor:'FORGE
QUAKE',zanni:'GRAND
FINALE',isolde:'SKYFALL
LANCES',rhea:'GRAND
ORRERY',solan:'SUNMANE
ROAR',browo:'HUTAN
SAWIT',browofire:'TORNADO
API SAWIT',nib:'SPECIAL
DELIVERY',edda:'ELDER
TORTOISE',arco:'HELIOS
SQUADRON'}[cutinKey];cutin.querySelector('.cutin-detail').textContent={fenr:'THE BEAST WITHIN',mira:'TWELVE-ROCKET SALVO',cora:'THREE-PASS RAVEN STORM',naja:'THREE-STRIKE SAND COBRA',haldor:'THREE-SLAM MOLTEN SHOCKWAVE',zanni:'THREE-RING BLADE BOOMERANG',isolde:'THREE-LANCE ICE DIVE',rhea:'THREE-PLANET ORBIT',solan:'THREE-ROAR SHOCKWAVE',browo:'TEN-PALM RUN + GIANT SLAM',browofire:'FIRE TORNADO FINISHER',nib:'THREE-PARCEL HOMING RUN',edda:'THREE-STOMP SPIRIT WALK',arco:'ORBITAL LASER STRIKE'}[cutinKey];cutin.setAttribute('aria-label',{fenr:'FENR ultimate: Feral Awakening',mira:'MIRA ultimate: Rocket Parade',cora:'CORA ultimate: Night Murmuration',naja:'NAJA ultimate: Dune Serpent',haldor:'HALDOR ultimate: Forge Quake',zanni:'ZANNI ultimate: Grand Finale',isolde:'ISOLDE ultimate: Skyfall Lances',rhea:'RHEA ultimate: Grand Orrery',solan:'SOLAN ultimate: Sunmane Roar',browo:'BRO WO ultimate: Hutan Sawit',browofire:'BRO WO rage ultimate: Tornado Api Sawit',nib:'NIB ultimate: Special Delivery',edda:'EDDA ultimate: Elder Tortoise',arco:'ARCO ultimate: Helios Squadron'}[cutinKey]);cfCutin(cutinKey,cutinOwner);} const visible = ct < SQUAD.cutinDuration; cutin.classList.toggle('visible', visible); cutin.setAttribute('aria-hidden', String(!visible)); const slide = window.Access?.reduced ? 0 : !visible ? -110 : ct < .16 ? -110 * Math.pow(1 - ct / .16,3) : ct > .57 ? 110 * Math.pow((ct-.57)/.21,2) : 0; cutin.style.transform = `translateX(${slide}%) rotate(-3deg)`; cutin.style.opacity = visible ? '1' : '0'; document.querySelectorAll('.combo-dots i').forEach((dot, i) => dot.classList.toggle('lit', hero.action?.type === 'attack' && hero.action.index > i)); } // Cut-in art per fighter. Only the two fighters of the current fight are warmed (at start/select), so a banner never waits // for its image; until the right image is decoded the art stays hidden (see updateHud) instead of showing the last one. const CUTIN_ART={fenr:'assets/fenr/ui/cutin.webp',mira:'assets/mira/ui/cutin.webp',cora:'assets/cora/ui/cutin.webp',naja:'assets/naja/ui/cutin.webp',haldor:'assets/haldor/ui/cutin.webp',zanni:'assets/zanni/ui/cutin.webp',isolde:'assets/isolde/ui/cutin.webp',rhea:'assets/rhea/ui/cutin.webp',solan:'assets/solan/ui/cutin.webp',browo:'assets/browo/ui/cutin.webp',browofire:'assets/browo/ui/cutin-fire.webp',nib:'assets/nib/ui/cutin.webp',edda:'assets/edda/ui/cutin.webp',arco:'assets/ui/ultimate-cutin.webp'}; for(const id of Object.keys(CF)){CUTIN_ART[id]='assets/'+id+'/ui/cutin.webp';if(CF[id].mode)CUTIN_ART[id+'~mode']='assets/'+id+'/ui/cutin-mode.webp';} let cutinWarm=[]; function warmCutins(){cutinWarm=[...new Set([selectedCharacter,opponentCharacter])].flatMap(id=>id==='browo'?[CUTIN_ART.browo,CUTIN_ART.browofire]:[CUTIN_ART[id],CUTIN_ART[id+'~mode']]).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;});} function setPaused(value) { paused = value; clearInput(); syncUltimateVoice(); $('#pause-btn').textContent = value ? '▶' : 'Ⅱ'; const panel = $('#pause-panel'); if (value && !panel.open) { try { openMoveList(); } catch (e) { console.error(e); } panel.showModal(); } if (!value && panel.open) panel.close(); if (!value) canvas.focus({ preventScroll: true }); } function openSettings() { settingsOpen = true; clearInput(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); $('#close-settings').innerHTML=(menuOpen?'Kembali ke menu':'Kembali ke arena')+' ↗'; $('#settings-panel').showModal(); } function closeSettings() { settingsOpen = false; $('#settings-panel').close(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); canvas.focus({ preventScroll: true }); } const handled = new Set(['w','a','s','d',' ','i','o','p','k','l','j','escape','r','arrowleft','arrowright','arrowup','n','m',',','.']); window.addEventListener('keydown', e => { if (!handled.has(e.key.toLowerCase()) || /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return; if (e.target.tagName === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return; e.preventDefault(); if (!e.repeat) press(e.key); }); window.addEventListener('keyup', e => { if (handled.has(e.key.toLowerCase())) { if(!/INPUT|TEXTAREA|SELECT|BUTTON/.test(e.target.tagName))e.preventDefault(); release(e.key); } }); window.addEventListener('blur', clearInput); document.addEventListener('visibilitychange', () => { clearInput(); lastTime = 0; accumulator = 0;if(document.hidden)systemAnnouncer?.clear(); syncUltimateVoice(); syncMusic(); }); window.addEventListener('resize', resize); canvas.addEventListener('pointerdown', () => { unlockAudio(); canvas.focus(); }); $('#pause-btn').onclick = () => { unlockAudio(); setPaused(!paused); }; $('#resume-btn').onclick = () => setPaused(false); $('#restart-btn').onclick = reset; $('#back-menu').onclick=()=>{closeSettings();setMenuOpen(true);window.FrontEnd?.home();}; $('#character-select').onchange=e=>selectCharacter(e.target.value); $('#ai-toggle').onchange=e=>{aiEnabled=e.target.checked;dummy.action=null;dummy.vx=0;dummy.state='idle';}; $('#settings-btn').onclick = openSettings; $('#close-settings').onclick = closeSettings; $('#settings-panel').addEventListener('cancel', e => { e.preventDefault(); closeSettings(); }); $('#pause-panel').addEventListener('cancel', e => { e.preventDefault(); setPaused(false); }); $('#hitbox-toggle').onchange = e => { hitboxes = e.target.checked; }; $('#sound-toggle').onchange = e => { unlockAudio(); muted = !e.target.checked; applyAudioMix(); }; $('#music-toggle').checked = musicOn; $('#music-slider').value = String(Math.round(musicVolume * 100)); $('#music-value').value = `${Math.round(musicVolume * 100)}%`; $('#music-toggle').onchange = e => { unlockAudio(); musicOn = e.target.checked; prefs.set('aether.music', musicOn ? '1' : '0'); syncMusic(); }; $('#music-slider').oninput = e => { unlockAudio(); musicVolume = clamp(Number(e.target.value) / 100, 0, 1); $('#music-value').value = `${e.target.value}%`; prefs.set('aether.musicVolume', e.target.value); syncMusic(); }; $('#volume-slider').oninput = e => { unlockAudio(); volume = Number(e.target.value) / 100; $('#volume-value').value = `${e.target.value}%`; applyAudioMix(); }; $('#sound-test').onclick = () => { unlockAudio(); sound('heavy'); }; // Inside the mobile shell the shell owns fullscreen (it also locks landscape); on its own page the game toggles it itself. function toggleFullscreen() { if (window.parent !== window) { try { if (window.parent.aetherFullscreen) { window.parent.aetherFullscreen(); return; } } catch (_) {} window.parent.postMessage({ type: 'aether-fullscreen' }, location.origin); return; } if (!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); else document.exitFullscreen?.(); } $('#fullscreen-btn').onclick = toggleFullscreen; for (const button of document.querySelectorAll('[data-action]')) button.onclick = () => { unlockAudio(); const name = button.dataset.action; if (name === 'attack') startAttack(); else cast(name); canvas.focus({ preventScroll: true }); }; function tick(stamp) { if (lastTime) { const elapsed = Math.min((stamp - lastTime) / 1000, .1); fps += (1 / Math.max(elapsed, .001) - fps) * .05; if (!window.__game?.manual) { accumulator += elapsed; while (accumulator >= 1 / 120) { step(1 / 120); accumulator -= 1 / 120; } } } lastTime = stamp; draw(); requestAnimationFrame(tick); } // Deterministic QA interface: set manual=true, reset(), press/release(), advance(seconds). // Positions represent feet. No networking or persistent state is changed by this interface. window.__game = { hero, dummy, config: CONFIG, balance:BALANCE, particles, effects, projectiles, pose: heroPose, press: key => press(key, true), release, input(key, down) { if (key.startsWith('cmd:')) { if (down) forceCommand(key.slice(4)); return; } if (down) press(key); else release(key); }, setP2(on) { p2Mode = !!on; if (on) { refreshPassives({ player: sideStyle.player, enemy: 'p2' }); aiEnabled = false; const t = $('#ai-toggle'); if (t) t.checked = false; } }, command: forceCommand, reset, cast, jump, attack: startAttack, clearInput, draw, manual: false, advance(seconds, noDraw) { for (let i = 0; i < Math.round(seconds * 120); i++) step(1 / 120); if (noDraw) return null; draw(); return this.snapshot(); }, snapshot() { return { ready, paused, settingsOpen, menuOpen, selectedCharacter, opponentCharacter, difficulty, stageId, match:match?{...match}:null,roundCues:[...roundCues],aiEnabled, time, stepCount, hero: JSON.parse(JSON.stringify(hero)), dummy: { ...dummy }, healthBars:{hero:hpLayers(hero.hp),dummy:hpLayers(currentDummyHP())}, squad: squad ? JSON.parse(JSON.stringify(squad)) : null, enemySquad:enemySquad?JSON.parse(JSON.stringify(enemySquad)):null, parade: parade ? JSON.parse(JSON.stringify(parade)) : null, enemyParade: enemyParade ? JSON.parse(JSON.stringify(enemyParade)) : null, flock: flock ? JSON.parse(JSON.stringify(flock)) : null, enemyFlock: enemyFlock ? JSON.parse(JSON.stringify(enemyFlock)) : null, serpent: serpent ? JSON.parse(JSON.stringify(serpent)) : null, enemySerpent: enemySerpent ? JSON.parse(JSON.stringify(enemySerpent)) : null, quake: quake ? JSON.parse(JSON.stringify(quake)) : null, enemyQuake: enemyQuake ? JSON.parse(JSON.stringify(enemyQuake)) : null, finale: finale ? { ...finale } : null, enemyFinale: enemyFinale ? { ...enemyFinale } : null, skyfall: skyfall ? { ...skyfall } : null, enemySkyfall: enemySkyfall ? { ...enemySkyfall } : null, orrery: orrery ? { ...orrery } : null, enemyOrrery: enemyOrrery ? { ...enemyOrrery } : null, sunroar: sunroar ? { ...sunroar } : null, enemySunroar: enemySunroar ? { ...enemySunroar } : null, delivery: delivery ? { ...delivery } : null, enemyDelivery: enemyDelivery ? { ...enemyDelivery } : null, tortoise: tortoise ? { ...tortoise, spirit: undefined } : null, enemyTortoise: enemyTortoise ? { ...enemyTortoise, spirit: undefined } : null, playable: playable(), cutinVisible: !!squad && squad.t < SQUAD.cutinDuration, totalDamage, comboHits, comboDamage, hitstop, cinematic, trauma, projectileCount: projectiles.length, audioState: audio?.state || 'uninitialized', audioSources, stageLoaded: !!images.stage, spriteLoaded: !!images.hero, manifestLoaded: !!manifest }; }, startMatch,setMenuOpen,menuSound,openSettings,toggleFullscreen,music(){return music?{on:musicOn,volume:musicVolume,playing:!music.paused,src:MUSIC_PATH,element:music}:null;},onRoundCue(fn){roundAnnouncer=typeof fn==='function'?fn:null;}, announceSelection(id){return menuOpen&&playable().includes(id)?(systemAnnouncer?.play('select_'+id)||false):false;}, stopAnnouncer(){systemAnnouncer?.clear();},announcerState(){return systemAnnouncer?.snapshot()||null;}, selectCharacter, setAI(value){aiEnabled=!!value;dummy.action=null;dummy.vx=0;dummy.state='idle';}, opponentPose, setPaused, setHitboxes(v) { hitboxes = !!v; $('#hitbox-toggle').checked = hitboxes; }, receiveHit(damage = 12,options={}) { if (menuOpen || (match&&match.phase!=='fight') || hero.hp<=0 || hero.invuln > 0) return false; if (hero.action?.guard && shellGuard(hero)) return false; if (damage > 0) damage = Math.max(1, passiveDamage(dummy, hero, damage, hitKind === 'basic')); hero.lastHurt = time; if (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; const counter = hero.action?.type === 'attack' && !hero.action.fired; hero.hp = Math.max(0, hero.hp - damage); hero.action = null; hero.rollBuffer = null; hero.doublePose = 0; hero.hurtTime = hero.hp>0?.42:0; hero.vx = hero.facing * -170; state(hero.hp>0?'hurt':'down'); spawnParticles(hero.x, hero.y - 75, '#ffc694', 14, 200); feelHeroHit(damage, options, counter); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; } }; // Pemeriksaan efek jurus untuk uji headless (tests/t1-fx.mjs). window.__game.fx = { snapshot: () => ({ ghosts: cfx.ghosts.length, hazards: cfx.hazards.filter(h => h.fire).length, dots: cfx.dots.length, later: cfx.later.length, particles: particles.length, effects: effects.map(e => e.type), tele: { hero: fxTeleP(hero), dummy: fxTeleP(dummy) }, projectiles: projectiles.map(p => ({ fx: p.fx, x: Math.round(p.x), y: Math.round(p.y), age: +(p.age || 0).toFixed(3), damage: p.damage, wait: p.wait || 0, orbit: !!(p.sw && !p.sw.go) })) }) }; // Uji headless layar VS (tests/t2-vsresult.mjs): vsActive = urutan ronde sedang menunggu layar VS; vsSkip melewatinya. Object.assign(window.__game, { vsActive: () => vsHold, vsSkip: () => !!window.VS?.force?.() }); // Pasif dan gaya (tests/t3-passive.mjs): angka dan gaya tiap sisi, titik kait, dan pengaturan gaya langsung. Object.assign(window.__game, { passiveMod, passives: () => ({ seed, player: { id: selectedCharacter, style: sideStyle.player, mods: { ...sideMods.player } }, enemy: { id: opponentCharacter, style: sideStyle.enemy, mods: { ...sideMods.enemy } } }), setStyles: styles => { refreshPassives(styles); hudIdentity = ''; return sideStyle; } }); // Hanya untuk uji headless (tests/t1-feel.mjs): pukulan langsung ke lawan, tabel rasa kena, status K.O./angka. Object.assign(window.__game, { hitDummy, feel: Feel, feelState: () => ({ ko: { ...Feel.ko.state, scale: Feel.ko.scale(), zoom: Feel.ko.zoom() }, kick: Feel.kickOffset(), numbers: damageNumbers.map(n => ({ amount: n.amount, kind: n.kind, label: n.label, color: n.color, life: n.life })), hitstop, trauma, realTime }) }); // Bayangan (RENCANA 3.6): damage musuh ke pemain +10% lewat satu pintu (semua serangan musuh memanggil window.__game.receiveHit; DoT lewat dotDamage). // shadowFoe() melaporkan apakah pertandingan sekarang melawan Bayangan (uji manual). { const baseHit = window.__game.receiveHit; window.__game.receiveHit = (damage, options) => baseHit(foeDamage(damage), options); } window.__game.shadowFoe = () => shadowFoe; if(F)reset(); if(window.FRONTEND_ENABLED)setMenuOpen(true); resize(); requestAnimationFrame(tick); // Wait for the first-visit download (preload.js) so these requests are served from the cache, not fetched twice. Promise.resolve(window.AETHER_PRELOAD).then(() => Promise.all([loadImage('hero', 'assets/mecha/run/sprite-sheet-alpha.webp', true), loadImage('stage', 'assets/stage.webp', true), ...(Rules?Object.entries(Rules.stages).filter(([id])=>id!=='bellora').map(([id,s])=>loadImage('stage-'+id,s.image,true)):[]), ...(F?[loadImage('fenrHuman','assets/fenr/human/run/sprite-sheet-alpha.webp',true),loadImage('fenrWolf','assets/fenr/wolf/run/sprite-sheet-alpha.webp',true),...['claw','gale','rush','bite','howl','transform'].map(name=>loadImage('fx-'+name,'assets/fenr/ui/fx-'+name+'.webp',true))]:[]), ...(M?[loadImage('mira','assets/mira/run/sprite-sheet-alpha.webp',true),...['star','rocket','burst','crash'].map(name=>loadImage('fx-mira-'+name,'assets/mira/ui/fx-'+name+'.webp',true))]:[]), ...(C?[loadImage('cora','assets/cora/run/sprite-sheet-alpha.webp',true),...['feather','gust','raven'].map(name=>loadImage('fx-cora-'+name,'assets/cora/ui/fx-'+name+'.webp',true))]:[]), ...(N?[loadImage('naja','assets/naja/run/sprite-sheet-alpha.webp',true),...['sandwave','cyclone','serpent','ripple'].map(name=>loadImage('fx-naja-'+name,'assets/naja/ui/fx-'+name+'.webp',true))]:[]), ...(HD?[loadImage('haldor','assets/haldor/run/sprite-sheet-alpha.webp',true),...['slag','splash','steam','quake'].map(name=>loadImage('fx-haldor-'+name,'assets/haldor/ui/fx-'+name+'.webp',true))]:[]), ...(Z?[loadImage('zanni','assets/zanni/run/sprite-sheet-alpha.webp',true),...['ring','bigring','snatch','confetti'].map(name=>loadImage('fx-zanni-'+name,'assets/zanni/ui/fx-'+name+'.webp',true))]:[]), ...(IS?[loadImage('isolde','assets/isolde/run/sprite-sheet-alpha.webp',true),...['piercer','skyfall','shatter','frost'].map(name=>loadImage('fx-isolde-'+name,'assets/isolde/ui/fx-'+name+'.webp',true))]:[]), ...(RH?[loadImage('rhea','assets/rhea/run/sprite-sheet-alpha.webp',true),...['drift','planet','well','burst'].map(name=>loadImage('fx-rhea-'+name,'assets/rhea/ui/fx-'+name+'.webp',true))]:[]), ...(SO?[loadImage('solan','assets/solan/run/sprite-sheet-alpha.webp',true),...['crescent','roar','impact','sunburst'].map(name=>loadImage('fx-solan-'+name,'assets/solan/ui/fx-'+name+'.webp',true))]:[]), ...(NB?[loadImage('nib','assets/nib/run/sprite-sheet-alpha.webp',true),...['letter','plane','slip','stamp'].map(name=>loadImage('fx-nib-'+name,'assets/nib/ui/fx-'+name+'.webp',true))]:[]), ...(BW?[loadImage('browo','assets/browo/run/sprite-sheet-alpha.webp',true),...['sawit','impact','sunburst','truck','burntree','tornado'].map(name=>loadImage('fx-browo-'+name,'assets/browo/ui/fx-'+name+'.webp',true))]:[]), ...Object.values(CF).flatMap(f=>[loadImage(f.id,'assets/'+f.id+'/run/sprite-sheet-alpha.webp',true),...(f.mode?.form?[loadImage(f.id+'-form','assets/'+f.id+'/form/run/sprite-sheet-alpha.webp',true)]:[]),...f.fx.map(n=>loadImage('fx-'+f.id+'-'+n,'assets/'+f.id+'/ui/fx-'+n+'.webp',true))]), ...(ED?[loadImage('edda','assets/edda/run/sprite-sheet-alpha.webp',true),...['stone','ripple','shell','tortoise','stomp'].map(name=>loadImage('fx-edda-'+name,'assets/edda/ui/fx-'+name+'.webp',true))]:[]), loadImage('drone', 'assets/ui/drone.png', true), loadImage('cutin', 'assets/ui/ultimate-cutin.webp', true)])).then(results => { images['stage-bellora']=images.stage;images.arco=images.hero;if(F)syncPlayerForm(); ready = results.every(Boolean) && (!F || (!!window.FENR_HUMAN_MANIFEST && !!window.FENR_WOLF_MANIFEST)) && !!manifest && frames('idle').length > 0; if (ready) { $('#load-state').classList.add('hidden'); announce('TRAINING READY', 1.65); } else { $('#load-state').classList.add('error'); $('#load-message').textContent = 'Aset arena belum lengkap. Periksa atlas/manifest karakter, stage.webp, drone.png, dan ultimate-cutin.webp, lalu muat ulang.'; } window.FrontEnd?.assetsReady(ready); }); })();