Efek jurus hidup: gelombang, area, dash, jurus mode, dan semut Gufron
Mesin efek bersama (canvas 2D, tanpa gambar baru): proyektil punya umur, muncul dengan overshoot, memanjang, irisan bergoyang, ekor bayangan, percikan, dan memudar. Gelombang suara kini berbeda per karakter (cincin berdenyut, teriakan dengan huruf, rantai cincin), ombak Ethanol Man bergulung dan mengangkat lawan. Jurus area punya cincin aba-aba 0,25 s, gelombang kejut, retakan, dan serpihan. Dash meninggalkan bayangan sprite, debu, dan garis kecepatan. Jurus mode mendapat mekanik berbeda lewat flag data: rebound, genangan api, split, korosi, belit, konvoi, ledakan susulan, sedotan. Bahasa Semut: 10 semut mengitari lalu mengejar, damage naik di tengah (maks 45). Pemain dan CPU memakai data dan efek yang sama; tests/t1-fx.mjs menguji semua jurus dari kedua sisi.
This commit is contained in:
parent
b1e3b75826
commit
d418f56d29
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -9,6 +9,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
|
||||||
- Serangan: `startAttack`, `cast`/`castMove`, `meleeHit`, `fireSkill`, `hitDummy` (pemain memukul lawan), `window.__game.receiveHit` (lawan memukul pemain), `startCommand`/`specialHit`/`startThrow`/`useShopMove`/`heroGuard` (gerakan perintah, lempar, blok/parry, jurus toko).
|
- Serangan: `startAttack`, `cast`/`castMove`, `meleeHit`, `fireSkill`, `hitDummy` (pemain memukul lawan), `window.__game.receiveHit` (lawan memukul pemain), `startCommand`/`specialHit`/`startThrow`/`useShopMove`/`heroGuard` (gerakan perintah, lempar, blok/parry, jurus toko).
|
||||||
- Karakter data-driven: `window.FIGHTERS[id]` (dari `assets/<id>/manifest.js`) → `cfKit(d)` (gerakan), `cfStrike(actor, def, move)` (efek pukul: jenis projectile/wave/homing/dash/area/pillar/lob; ultimate volley/stampede/rain/pillars via `updateCfUltimate`). Bro Wo ditulis tangan (`browo.js` + `updateBrowo`, `browoStrike`).
|
- Karakter data-driven: `window.FIGHTERS[id]` (dari `assets/<id>/manifest.js`) → `cfKit(d)` (gerakan), `cfStrike(actor, def, move)` (efek pukul: jenis projectile/wave/homing/dash/area/pillar/lob; ultimate volley/stampede/rain/pillars via `updateCfUltimate`). Bro Wo ditulis tangan (`browo.js` + `updateBrowo`, `browoStrike`).
|
||||||
- Proyektil: array `projectiles`, diperbarui di `updateProjectiles(dt)`; efek visual: array `effects` (tipe `slash`, `impact`, `cf-fx`, `cf-pillar`, ...) digambar di `drawEffects()`; partikel `spawnParticles`; angka damage `damageNumbers`.
|
- Proyektil: array `projectiles`, diperbarui di `updateProjectiles(dt)`; efek visual: array `effects` (tipe `slash`, `impact`, `cf-fx`, `cf-pillar`, ...) digambar di `drawEffects()`; partikel `spawnParticles`; angka damage `damageNumbers`.
|
||||||
|
- Efek jurus (blok "Efek jurus (FX)" di game.js, dipakai pemain dan CPU lewat `cfStrike`): proyektil CF punya umur (`p.age`) dan gaya gambar `p.fx` (rings, shout, chain, roll, spray, flame, bull, drive, wave, bolt, ant); `fxPart` membuat partikel kosmetik (pengacak terpisah `fxr()`, bukan `random()`); jurus area punya aba-aba cincin 0,25 s (`fxTeleP`), gelombang kejut dan retakan; dash meninggalkan bayangan sprite (`cfx.ghosts`). Flag data di manifest per skill (juga `mode.skills`): `fx`, `lift`, `trail`, `split`, `dot`, `bind`, `convoy`, `aftershock`, `pull`, `rebound`, `swarm`+`ramp`, `fire`; artinya ada di komentar "Mekanik jurus" di game.js.
|
||||||
- CPU: `updateFenrAI`, `cpuProfile` (tingkat easy/medium/hard/excellent dari `match.js`).
|
- CPU: `updateFenrAI`, `cpuProfile` (tingkat easy/medium/hard/excellent dari `match.js`).
|
||||||
- Aturan ronde: `match.js` (`MatchRules`), dipakai `advanceMatch` di game.js.
|
- Aturan ronde: `match.js` (`MatchRules`), dipakai `advanceMatch` di game.js.
|
||||||
- `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`.
|
- `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`.
|
||||||
|
|
@ -20,8 +21,10 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
|
||||||
## API uji: `window.__game`
|
## API uji: `window.__game`
|
||||||
`startMatch({player,enemy,stage,level,mode})` (mode `versus`|`training`), `advance(detik)` (memajukan simulasi tanpa menunggu waktu nyata), `press(key)`/`release(key)` (a d w s spasi i o p k l j; P2: arrowleft arrowright arrowup n m , .), `setAI(bool)`, `setP2(bool)`, `snapshot()`, `hero`, `dummy`, `projectiles`, `effects`, `draw()`, `receiveHit(dmg)`, `command(id)`. Layar tidak diperbarui saat `advance`; panggil `draw()` sebelum screenshot.
|
`startMatch({player,enemy,stage,level,mode})` (mode `versus`|`training`), `advance(detik)` (memajukan simulasi tanpa menunggu waktu nyata), `press(key)`/`release(key)` (a d w s spasi i o p k l j; P2: arrowleft arrowright arrowup n m , .), `setAI(bool)`, `setP2(bool)`, `snapshot()`, `hero`, `dummy`, `projectiles`, `effects`, `draw()`, `receiveHit(dmg)`, `command(id)`. Layar tidak diperbarui saat `advance`; panggil `draw()` sebelum screenshot.
|
||||||
|
|
||||||
|
`__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot.
|
||||||
|
|
||||||
## Alat uji headless
|
## Alat uji headless
|
||||||
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. Node dan Playwright sudah terpasang; tidak perlu `npm install`.
|
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`.
|
||||||
|
|
||||||
## Aturan kerja
|
## Aturan kerja
|
||||||
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).
|
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).
|
||||||
|
|
|
||||||
389
game.js
389
game.js
|
|
@ -836,7 +836,7 @@
|
||||||
// projectile | wave | homing | dash | area | pillar | lob (skill1 / skill2 / basic chain is melee)
|
// 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
|
// 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.
|
// 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: [] };
|
const cfx = { ents: [], hazards: [], marks: [], later: [], dots: [], ghosts: [] };
|
||||||
function cfKit(d) {
|
function cfKit(d) {
|
||||||
const move = (name, index = 1, mode = false) => {
|
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 === 'attack') { const c = d.combo[index - 1]; return { name: `attack${index}`, type: 'attack', index, ...c, reach: Math.round((c.reach || 105) * (CONFIG.reachScale || 1)) }; }
|
||||||
|
|
@ -869,30 +869,44 @@
|
||||||
move = { ...move, damage: Math.round((move.damage || 0) * mul), reach: (move.reach || 0) + radd };
|
move = { ...move, damage: Math.round((move.damage || 0) * mul), reach: (move.reach || 0) + radd };
|
||||||
const k = move.kind;
|
const k = move.kind;
|
||||||
if (k === 'projectile' || k === 'wave' || k === 'homing') {
|
if (k === 'projectile' || k === 'wave' || k === 'homing') {
|
||||||
const n = move.count || 1, ox = actor.x + actor.facing * Math.round((em?.x ?? 100) * .7);
|
const n = move.count || 1, ox = actor.x + actor.facing * Math.round((em?.x ?? 100) * .7), cv = move.convoy, grp = { hits: 0 };
|
||||||
for (let i = 0; i < n; i++) {
|
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), vx: actor.facing * move.speed, life: move.life || (move.range || 560) / move.speed, facing: actor.facing, owner, damage: 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 };
|
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; }
|
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);
|
projectiles.push(p);
|
||||||
}
|
}
|
||||||
spawnParticles(ox, actor.y + (move.y ?? -95), col, 10, 160); sound('cast'); return;
|
spawnParticles(ox, actor.y + (move.y ?? -95), col, 10, 160); sound('cast'); return;
|
||||||
}
|
}
|
||||||
if (k === 'lob') {
|
if (k === 'lob') {
|
||||||
const T = .85, vx = clamp((target.x - actor.x) / T, -720, 720);
|
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 }, 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 });
|
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;
|
sound('cast'); return;
|
||||||
}
|
}
|
||||||
let hitX = null, inside;
|
let hitX = null, inside;
|
||||||
if (k === 'dash') {
|
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 });
|
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;
|
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') {
|
} else if (k === 'area' || k === 'pillar') {
|
||||||
hitX = actor.x + actor.facing * (move.ahead || 0);
|
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 });
|
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 });
|
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 });
|
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);
|
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);
|
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
|
} 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 });
|
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); }
|
if (move.index === 3) { dust(actor.x + actor.facing * (em?.x ?? 95), 12); trauma = Math.min(1, trauma + .15); }
|
||||||
|
|
@ -915,6 +929,7 @@
|
||||||
if (bwAlive(target) && Math.abs(target.x - p.x) < L.radius && target.y > CONFIG.groundY - 120) dotDamage(target, L.burst, p.color);
|
if (bwAlive(target) && Math.abs(target.x - p.x) < L.radius && target.y > CONFIG.groundY - 120) dotDamage(target, L.burst, p.color);
|
||||||
}
|
}
|
||||||
function updateCf(dt) {
|
function updateCf(dt) {
|
||||||
|
fxTick(dt);
|
||||||
// Entities (ultimate volleys and stampedes): move, then land one hit each on the rival they cross.
|
// 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--) {
|
for (let n = cfx.ents.length - 1; n >= 0; n--) {
|
||||||
const e = cfx.ents[n], target = e.owner === 'enemy' ? hero : dummy;
|
const e = cfx.ents[n], target = e.owner === 'enemy' ? hero : dummy;
|
||||||
|
|
@ -938,17 +953,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Puddles: one burn per rival, however many puddles they stand in.
|
// Puddles: one burn per rival, however many puddles they stand in.
|
||||||
const inside = { player: false, enemy: false }, hz = {};
|
const inside = { player: false, enemy: false }, hz = {}, hcol = {};
|
||||||
for (let n = cfx.hazards.length - 1; n >= 0; n--) {
|
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;
|
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); }
|
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);
|
if (h.t > h.life) cfx.hazards.splice(n, 1);
|
||||||
}
|
}
|
||||||
for (const k of ['player', 'enemy']) {
|
for (const k of ['player', 'enemy']) {
|
||||||
if (!inside[k]) continue;
|
if (!inside[k]) continue;
|
||||||
const target = k === 'player' ? hero : dummy; bwx.burn[k] += hz[k] * dt;
|
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, '#9bd05a', false); }
|
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: '#9bd05a' }); bwx.burn.shown[k] = 0; }
|
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.
|
// The ultimate director. s.t counts from the cast; beats start after `hold` seconds of camera dive.
|
||||||
|
|
@ -1012,7 +1027,15 @@
|
||||||
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;
|
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();
|
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) {
|
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;
|
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), CONFIG.groundY + 6 - hh, w, hh); ctx.restore();
|
ctx.save(); ctx.globalAlpha = .9 * fade; ctx.drawImage(im, Math.round(h.x - w / 2), CONFIG.groundY + 6 - hh, w, hh); ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
@ -1029,12 +1052,331 @@
|
||||||
ctx.restore();
|
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 && 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 -------------------------------------------
|
// ---- 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.
|
// `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 };
|
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() {
|
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.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 = 0;
|
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;
|
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(); }
|
{ const b = bwBaseCooldowns(selectedCharacter); if (b) Object.assign(cooldownMax, b); if (CF[selectedCharacter]?.mode?.form) syncPlayerForm(); }
|
||||||
}
|
}
|
||||||
|
|
@ -1386,12 +1728,14 @@
|
||||||
}
|
}
|
||||||
function updateProjectiles(dt) {
|
function updateProjectiles(dt) {
|
||||||
for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i], oldX = p.x;
|
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.
|
// 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; }
|
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.
|
// 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.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.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.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.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; }
|
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.
|
// RHEA: a Gravity Well waits in place and collapses; an Orrery planet laps around its owner.
|
||||||
|
|
@ -1409,11 +1753,11 @@
|
||||||
}
|
}
|
||||||
if (p.spent) { if (p.life <= 0) projectiles.splice(i, 1); continue; }
|
if (p.spent) { if (p.life <= 0) projectiles.splice(i, 1); continue; }
|
||||||
if(p.owner==='enemy') {
|
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}))p.boomerang?(p.spent=true,p.returning=true):(p.orbit||p.pass)?(p.spent=true):p.life=0;
|
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;
|
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.
|
// 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.boomerang) { p.spent = true; p.returning = true; } else if (p.orbit || p.pass) p.spent = true; else p.life = 0; } }
|
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);
|
if (p.life <= 0 || p.x < -100 || p.x > W + 100) projectiles.splice(i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1770,7 +2114,7 @@
|
||||||
function cpuReach(m) { return m.projectile ? (m.range || 560) : (m.area ? m.reach : m.reach + 27) + (m.dash ? m.dash * m.duration * .4 : 0); }
|
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).
|
// 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 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) : 0); }
|
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,
|
// 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:
|
// 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.
|
// only the confirmed chain (combo + one ender) may use the stun, so there are no infinite loops.
|
||||||
|
|
@ -1978,7 +2322,7 @@
|
||||||
return {state:name,frame:clamp(index,0,Math.max(0,list.length-1)),manifest:m};
|
return {state:name,frame:clamp(index,0,Math.max(0,list.length-1)),manifest:m};
|
||||||
}
|
}
|
||||||
function updateVisuals(dt) {
|
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.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680) * dt; p.vx *= Math.exp(-dt * 3); if (p.life <= 0) particles.splice(i, 1); }
|
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); }
|
for (let i = effects.length - 1; i >= 0; i--) { effects[i].life -= dt; if (effects[i].life <= 0) effects.splice(i, 1); }
|
||||||
for (let i = damageNumbers.length - 1; i >= 0; i--) { damageNumbers[i].life -= dt; damageNumbers[i].y -= dt * 55; if (damageNumbers[i].life <= 0) damageNumbers.splice(i, 1); }
|
for (let i = damageNumbers.length - 1; i >= 0; i--) { damageNumbers[i].life -= dt; damageNumbers[i].y -= dt * 55; if (damageNumbers[i].life <= 0) damageNumbers.splice(i, 1); }
|
||||||
}
|
}
|
||||||
|
|
@ -2116,6 +2460,7 @@
|
||||||
}
|
}
|
||||||
function drawEffects() {
|
function drawEffects() {
|
||||||
for (const p of projectiles) {
|
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;}
|
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);
|
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();
|
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();
|
||||||
|
|
@ -2133,6 +2478,15 @@
|
||||||
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 === '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 === '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 === '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') {
|
if (e.type === 'drone-laser') {
|
||||||
const dx = e.endX - e.x, dy = e.endY - e.y, length = Math.hypot(dx, dy);
|
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';
|
ctx.rotate(Math.atan2(dy, dx)); ctx.lineCap = 'round';
|
||||||
|
|
@ -2145,13 +2499,14 @@
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
for (const p of particles) {
|
for (const p of particles) {
|
||||||
ctx.save(); ctx.globalAlpha=clamp(p.life/.3,0,1); ctx.fillStyle=p.color; const size=Math.max(1,Math.round(p.size));
|
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') {
|
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);
|
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);
|
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');
|
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();
|
ctx.globalAlpha*=.9; ctx.fillStyle=fog; ctx.beginPath(); ctx.arc(0,0,radius,0,Math.PI*2); ctx.fill();
|
||||||
} else if(p.kind==='dust') {ctx.globalAlpha*=.35;ctx.fillRect(Math.round(p.x),Math.round(p.y),size*3,size*2);}
|
} 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);
|
else ctx.fillRect(Math.round(p.x),Math.round(p.y),size,size);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
@ -2383,6 +2738,8 @@
|
||||||
setPaused, setHitboxes(v) { hitboxes = !!v; $('#hitbox-toggle').checked = hitboxes; },
|
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 (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; 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); if(options.freeze!==false)hitstop = .05; trauma = Math.min(1, trauma + .2 * (options.shake ?? 1)); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; }
|
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 (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; 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); if(options.freeze!==false)hitstop = .05; trauma = Math.min(1, trauma + .2 * (options.shake ?? 1)); 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) })) }) };
|
||||||
if(F)reset();
|
if(F)reset();
|
||||||
if(window.FRONTEND_ENABLED)setMenuOpen(true);
|
if(window.FRONTEND_ENABLED)setMenuOpen(true);
|
||||||
resize(); requestAnimationFrame(tick);
|
resize(); requestAnimationFrame(tick);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
// Tahap 1.1-1.6: efek jurus (gelombang, area, dash, jurus mode, semut Gufron).
|
||||||
|
// node tests/harness.mjs tests/t1-fx.mjs
|
||||||
|
// Setiap jurus (skill1, skill2, versi mode) enam karakter berbasis data dilepas dari sisi pemain DAN sisi lawan (P2/CPU memakai
|
||||||
|
// data jurus yang sama). Menguji: damage tetap dalam batas, waktu kena tidak bergeser, efek benar-benar bergerak, mekanik jurus
|
||||||
|
// mode (rebound, trail, split, dot, bind, convoy, aftershock, pull), dan mengambil tangkapan layar ke FX_SHOTS (default
|
||||||
|
// <tmp>/t1-fx-shots) untuk ditinjau mata. Gagal bila ada pernyataan yang salah atau galat halaman.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const options = { viewport: { width: 1280, height: 720 } };
|
||||||
|
const OUT = process.env.FX_SHOTS || path.join(os.tmpdir(), 't1-fx-shots');
|
||||||
|
const DT = 1 / 60;
|
||||||
|
|
||||||
|
// Daftar jurus: id, skill, mode?, jarak target (D) agar kena, kind, dan waktu tangkapan layar setelah dilepas.
|
||||||
|
const MOVES = [
|
||||||
|
{ c: 'megachan', s: 'skill1', k: 'dash', D: 300, T: [.15, .25, .36, .55] },
|
||||||
|
{ c: 'megachan', s: 'skill2', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 16 },
|
||||||
|
{ c: 'megachan', s: 'skill1', m: 1, k: 'rebound', D: 300, T: [.3, .5, .8, 1.1] },
|
||||||
|
{ c: 'megachan', s: 'skill2', m: 1, k: 'trail', D: 520, T: [.45, .6, .9, 1.6] },
|
||||||
|
{ c: 'sonofsolo', s: 'skill1', k: 'lob', D: 450, T: [.4, .7, 1.0, 1.4] },
|
||||||
|
{ c: 'sonofsolo', s: 'skill2', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 15 },
|
||||||
|
{ c: 'sonofsolo', s: 'skill1', m: 1, k: 'dot', D: 480, T: [.4, .55, .8, 1.5] },
|
||||||
|
{ c: 'sonofsolo', s: 'skill2', m: 1, k: 'lob', D: 450, T: [.4, .7, 1.0, 1.6] },
|
||||||
|
{ c: 'ethanolman', s: 'skill1', k: 'wave', D: 480, T: [.4, .5, .62, .8], base: 17, lift: true },
|
||||||
|
{ c: 'ethanolman', s: 'skill2', k: 'area', D: 100, T: [.15, .3, .4, .7] },
|
||||||
|
{ c: 'ethanolman', s: 'skill1', m: 1, k: 'trail', D: 500, T: [.45, .6, .9, 1.6] },
|
||||||
|
{ c: 'ethanolman', s: 'skill2', m: 1, k: 'aftershock', D: 100, T: [.15, .32, .45, .7] },
|
||||||
|
{ c: 'anice', s: 'skill1', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 14 },
|
||||||
|
{ c: 'anice', s: 'skill2', k: 'area', D: 110, T: [.15, .34, .45, .8] },
|
||||||
|
{ c: 'anice', s: 'skill1', m: 1, k: 'dash', D: 300, T: [.15, .25, .36, .55] },
|
||||||
|
{ c: 'anice', s: 'skill2', m: 1, k: 'split', D: 560, T: [.45, .65, .85, 1.1] },
|
||||||
|
{ c: 'soloman', s: 'skill1', k: 'dash', D: 300, T: [.15, .25, .36, .55] },
|
||||||
|
{ c: 'soloman', s: 'skill2', k: 'area', D: 100, T: [.15, .3, .4, .7] },
|
||||||
|
{ c: 'soloman', s: 'skill1', m: 1, k: 'convoy', D: 520, T: [.4, .55, .7, .95] },
|
||||||
|
{ c: 'soloman', s: 'skill2', m: 1, k: 'pull', D: 330, T: [.1, .25, .34, .6] },
|
||||||
|
{ c: 'gufron', s: 'skill1', k: 'ants', D: 480, T: [.3, .6, 1.0, 1.4] },
|
||||||
|
{ c: 'gufron', s: 'skill2', k: 'wave', D: 520, T: [.4, .5, .62, .8], base: 15 },
|
||||||
|
{ c: 'gufron', s: 'skill1', m: 1, k: 'ants', D: 480, T: [.3, .6, 1.0, 1.4] },
|
||||||
|
{ c: 'gufron', s: 'skill2', m: 1, k: 'bind', D: 480, T: [.4, .55, .7, .95] },
|
||||||
|
];
|
||||||
|
const OTHER = { megachan: 'browo', sonofsolo: 'browo', ethanolman: 'browo', anice: 'browo', soloman: 'browo', gufron: 'browo' };
|
||||||
|
|
||||||
|
export default async ({ page, game, errors }) => {
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const fails = [], log = [];
|
||||||
|
const check = (name, ok, info = '') => { if (!ok) fails.push(`${name} ${info}`); };
|
||||||
|
|
||||||
|
await game(() => {
|
||||||
|
// Pembantu di dalam halaman: menyiapkan satu jurus, melepasnya, lalu memajukan waktu sambil mencatat sampel.
|
||||||
|
window.__fxt = {
|
||||||
|
setup({ c, o, side, mode, D }) {
|
||||||
|
const g = window.__game; g.manual = true;
|
||||||
|
g.startMatch({ player: side === 'e' ? o : c, enemy: side === 'e' ? c : o, stage: 'bellora', level: 'easy', mode: 'training' });
|
||||||
|
g.setAI(false); if (side === 'e') g.setP2(true);
|
||||||
|
g.advance(1.5);
|
||||||
|
if (mode) { const k = side === 'e' ? '.' : 'p'; g.press(k); g.release(k); g.advance(7.6); }
|
||||||
|
g.hero.hp = 200; g.dummy.damage = 0; g.hero.invuln = 0; g.dummy.invuln = 0;
|
||||||
|
g.hero.cooldowns.skill1 = g.hero.cooldowns.skill2 = 0; g.dummy.cooldowns.skill1 = g.dummy.cooldowns.skill2 = 0;
|
||||||
|
if (side === 'e') { g.dummy.x = 930; g.hero.x = 930 - D; } else { g.hero.x = 350; g.dummy.x = 350 + D; }
|
||||||
|
g.advance(.2);
|
||||||
|
},
|
||||||
|
cast(side, skill) { const g = window.__game, k = side === 'e' ? (skill === 'skill1' ? 'm' : ',') : (skill === 'skill1' ? 'i' : 'o'); g.press(k); g.release(k); },
|
||||||
|
sample(side) {
|
||||||
|
const g = window.__game, t = side === 'e' ? g.hero : g.dummy, fx = g.fx.snapshot();
|
||||||
|
return { dmg: side === 'e' ? 200 - g.hero.hp : g.dummy.damage, x: t.x, y: t.y, st: side === 'e' ? g.hero.state : g.dummy.state, hurt: side === 'e' ? g.hero.hurtTime > 0 : g.dummy.state === 'hurt', fx };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const side of ['p', 'e']) for (const mv of MOVES) {
|
||||||
|
const name = `${side === 'p' ? 'pemain' : 'lawan'}:${mv.c}.${mv.m ? 'mode.' : ''}${mv.s}`;
|
||||||
|
if (process.env.FX_ONLY && !new RegExp(process.env.FX_ONLY).test(name)) continue;
|
||||||
|
await game(o => window.__fxt.setup(o), { c: mv.c, o: OTHER[mv.c], side, mode: !!mv.m, D: mv.D });
|
||||||
|
const x0 = await game(side => window.__fxt.sample(side).x, side);
|
||||||
|
await game(({ side, s }) => window.__fxt.cast(side, s), { side, s: mv.s });
|
||||||
|
const rows = []; let t = 0, shot = 0;
|
||||||
|
const end = Math.max(...mv.T) + (mv.k === 'dot' || mv.k === 'trail' ? 2.2 : mv.k === 'ants' ? 2.8 : .6);
|
||||||
|
while (t < end) {
|
||||||
|
await game(d => window.__game.advance(d), DT); t += DT;
|
||||||
|
const r = await game(side => window.__fxt.sample(side), side); r.t = +t.toFixed(3); rows.push(r);
|
||||||
|
if (shot < mv.T.length && t >= mv.T[shot] - 1e-6) { await page.screenshot({ path: path.join(OUT, `${side}_${mv.c}_${mv.m ? 'm_' : ''}${mv.s}_${shot}.png`), clip: { x: 0, y: 330, width: 1280, height: 290 } }); shot++; }
|
||||||
|
}
|
||||||
|
const dmgEnd = rows[rows.length - 1].dmg, firstHit = rows.find(r => r.dmg > 0)?.t ?? null;
|
||||||
|
const hitsBig = rows.reduce((n, r, i) => n + (i && r.dmg - rows[i - 1].dmg >= 5 ? 1 : 0), 0);
|
||||||
|
const moved = rows.some(r => r.fx.projectiles.some(p => p.age > .1)) || rows.some(r => r.fx.ghosts > 0 || r.fx.tele.hero >= 0 || r.fx.tele.dummy >= 0 || r.fx.effects.length);
|
||||||
|
log.push(`${name}: dmg ${dmgEnd} 1stHit ${firstHit} hits>=5 ${hitsBig}`);
|
||||||
|
check(name + ' efek tidak tampil/bergerak', moved);
|
||||||
|
check(name + ' tidak ada partikel berlebih', rows.every(r => r.fx.particles <= 470), String(Math.max(...rows.map(r => r.fx.particles))));
|
||||||
|
|
||||||
|
if (mv.k === 'wave' && !mv.m) {
|
||||||
|
// damage total tetap dalam +-10% dari angka sebelum efek dipoles (satu pukulan)
|
||||||
|
check(name + ' damage ' + dmgEnd, dmgEnd >= mv.base * .9 && dmgEnd <= mv.base * 1.1 + .01, `base ${mv.base}`);
|
||||||
|
const ids = new Set(rows.flatMap(r => r.fx.projectiles.map(p => p.fx)));
|
||||||
|
log.push(` gaya: ${[...ids].join(',')}`);
|
||||||
|
if (mv.lift) { const ground = rows[0].y; check(name + ' lawan terangkat', rows.some(r => r.y < ground - 20), 'y min ' + Math.min(...rows.map(r => r.y))); }
|
||||||
|
}
|
||||||
|
if (mv.k === 'area') {
|
||||||
|
const maxTele = Math.max(...rows.map(r => Math.max(r.fx.tele.hero, r.fx.tele.dummy)));
|
||||||
|
check(name + ' ada aba-aba cincin', maxTele > .9, String(maxTele));
|
||||||
|
const firstTele = rows.find(r => r.fx.tele.hero >= 0 || r.fx.tele.dummy >= 0)?.t;
|
||||||
|
check(name + ' aba-aba ~0,25 s sebelum kena', firstHit !== null && firstTele !== undefined && firstHit - firstTele > .18 && firstHit - firstTele < .32, `tele ${firstTele} hit ${firstHit}`);
|
||||||
|
check(name + ' kena di waktu semula (0,29-0,32 s)', firstHit !== null && firstHit >= .27 && firstHit <= .35, String(firstHit));
|
||||||
|
check(name + ' gelombang kejut + retakan', rows.some(r => r.fx.effects.includes('cf-shock')) && rows.some(r => r.fx.effects.includes('cf-crack')));
|
||||||
|
}
|
||||||
|
if (mv.k === 'dash') check(name + ' bayangan jejak', rows.some(r => r.fx.ghosts >= 3), String(Math.max(...rows.map(r => r.fx.ghosts))));
|
||||||
|
if (mv.k === 'rebound') { check(name + ' menembus lalu memantul (2 pukulan)', hitsBig === 2, String(hitsBig)); check(name + ' total ~35', dmgEnd >= 32 && dmgEnd <= 40, String(dmgEnd)); }
|
||||||
|
if (mv.k === 'trail') { check(name + ' genangan api', Math.max(...rows.map(r => r.fx.hazards)) >= 3, String(Math.max(...rows.map(r => r.fx.hazards)))); check(name + ' membakar berkala setelah kena', dmgEnd > 31, String(dmgEnd)); }
|
||||||
|
if (mv.k === 'split') { check(name + ' membelah jadi dua', rows.some(r => r.fx.projectiles.filter(p => p.fx === 'wave').length >= 2)); check(name + ' kedua belahan kena (total ~36)', dmgEnd >= 34 && dmgEnd <= 40, String(dmgEnd)); }
|
||||||
|
if (mv.k === 'dot') { check(name + ' korosi aktif', rows.some(r => r.fx.dots > 0)); const h = rows.findIndex(r => r.dmg > 0); check(name + ' damage berkala', h >= 0 && dmgEnd - rows[h + 20].dmg >= 4, `${rows[h + 20]?.dmg} -> ${dmgEnd}`); }
|
||||||
|
if (mv.k === 'bind') { const h = rows.findIndex(r => r.dmg > 0); const held = h >= 0 ? rows.slice(h, h + 33).filter(r => r.hurt).length * DT : 0; check(name + ' lawan terbelit >= 0,5 s', held >= .5, String(held)); }
|
||||||
|
if (mv.k === 'convoy') check(name + ' tiga mobil beruntun', hitsBig >= 3, String(hitsBig));
|
||||||
|
if (mv.k === 'aftershock') { check(name + ' ledakan kedua', hitsBig >= 2, String(hitsBig)); check(name + ' ledakan kedua tertunda', rows.some(r => r.fx.later > 0)); }
|
||||||
|
if (mv.k === 'pull') { const xs = rows.filter(r => r.t < .35).map(r => r.x); const pulled = Math.abs(xs[0] - xs[xs.length - 1]); check(name + ' lawan tersedot', pulled > 30, String(pulled)); }
|
||||||
|
if (mv.k === 'ants') {
|
||||||
|
check(name + ' semut mengitari dulu', rows.some(r => r.fx.projectiles.some(p => p.orbit)));
|
||||||
|
const n = Math.max(...rows.map(r => r.fx.projectiles.filter(p => p.fx === 'ant').length));
|
||||||
|
check(name + ' jumlah semut ' + n, n >= (mv.m ? 11 : 10), String(n));
|
||||||
|
check(name + ' total damage <= 45', dmgEnd <= 46, String(dmgEnd));
|
||||||
|
check(name + ' semut mengenai', dmgEnd >= 28, String(dmgEnd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semut mengitari dulu lalu mengejar: tidak ada semut yang mengenai selama mengitari.
|
||||||
|
await game(o => window.__fxt.setup(o), { c: 'gufron', o: 'browo', side: 'p', mode: false, D: 150 });
|
||||||
|
await game(() => window.__fxt.cast('p', 'skill1'));
|
||||||
|
const early = await game(() => { const g = window.__game; g.advance(.5); return g.dummy.damage; });
|
||||||
|
check('semut tidak mengenai sebelum selesai mengitari (lawan di dekat)', early === 0, String(early));
|
||||||
|
|
||||||
|
// CPU memakai data jurus dan efek yang sama: 45 detik melawan pemain yang diam, lalu periksa gaya efek yang muncul.
|
||||||
|
const EXPECT = { megachan: ['rings', 'ghost'], sonofsolo: ['shout'], ethanolman: ['roll', 'cf-shock'], anice: ['bolt', 'cf-shock'], soloman: ['ghost', 'cf-shock'], gufron: ['ant', 'chain'] };
|
||||||
|
for (const [c, want] of Object.entries(EXPECT)) {
|
||||||
|
const seen = await game(({ c }) => {
|
||||||
|
const g = window.__game; g.manual = true; g.startMatch({ player: 'browo', enemy: c, stage: 'bellora', level: 'hard', mode: 'training' }); g.setAI(true);
|
||||||
|
const set = new Set();
|
||||||
|
for (let i = 0; i < 900; i++) { g.advance(.05); const f = g.fx.snapshot(); f.projectiles.forEach(p => set.add(p.fx)); f.effects.forEach(e => set.add(e)); if (f.ghosts) set.add('ghost'); if (g.hero.hp <= 0) g.hero.hp = 200; }
|
||||||
|
return [...set];
|
||||||
|
}, { c });
|
||||||
|
log.push(`CPU ${c}: ${seen.join(',')}`);
|
||||||
|
check(`CPU ${c} memakai efek jurus`, want.some(w => seen.includes(w)), `diharapkan salah satu dari ${want}, terlihat ${seen}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Karakter lain (Bro Wo, Arco) tetap berfungsi: lepaskan semua jurus tanpa galat.
|
||||||
|
for (const c of ['browo']) await game(async c => { const g = window.__game; g.manual = true; g.startMatch({ player: c, enemy: 'megachan', stage: 'bellora', level: 'hard', mode: 'versus' }); g.setAI(true); for (let i = 0; i < 40; i++) { g.advance(.25); g.press('i'); g.release('i'); g.press('o'); g.release('o'); } }, c);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUT, 'log.txt'), log.join('\n'));
|
||||||
|
if (fails.length) throw new Error('GAGAL (' + fails.length + '):\n' + fails.join('\n') + '\n\nLog:\n' + log.join('\n'));
|
||||||
|
return { ok: true, moves: MOVES.length * 2, shots: OUT, log };
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue