이펙트 시스템: 로켓 폴리라인 + 레일건 나선
비주얼 이펙트는 게임의 첫인상을 좌우합니다. 같은 데미지여도 멋있게 폭발하는 무기가 더 강해 보이고, 더 잘 쓰입니다. Dogkov Arena의 이펙트는 모두 Canvas 2D로 그려지지만 몇 가지 트릭으로 풍부한 느낌을 냈습니다.
이펙트 시스템 구조
두 가지로 나뉩니다:
- 일회성 파티클 —
fx[]배열에 push, 매 step에서 lifetime 체크해 만료된 것 제거. 폭발/스파크/슬래시/연기 puff 등. - 지속 트레일 — 발사체 ID 별로 별도 Map에 폴리라인 유지. 발사체가 살아있는 동안 매 프레임 끝 점 추가, 시간 따라 점이 드리프트하고 페이드.
왜 트레일이 별도냐: 발사체에서 "연결된" 라인을 그리려면 어떤 점이 어느 발사체 소속인지 알아야 하기 때문입니다.
로켓 트레일 v1: 토막
처음엔 0.25초마다 별도 line segment를 fx[]에 push했습니다. 결과: 토막난 막대기들이 하나씩 떨어지는 느낌. 사용자 피드백: "나무막대기가 하나씩 나오는 느낌". 폴리라인으로 가야 한다는 신호.
로켓 트레일 v2: 연결 폴리라인
const rocketTrails = new Map(); // id -> { points: [{x,y,born,dx,dy}], lastSpawnT }
function spawnProjectileTrails(projectiles, now) {
for (const pr of projectiles) {
if (pr.w !== 'rocket') continue;
let trail = rocketTrails.get(pr.id);
if (!trail) {
trail = { points: [], lastSpawnT: 0 };
rocketTrails.set(pr.id, trail);
}
if (now - trail.lastSpawnT >= 250) {
// perpendicular drift 방향: 모션 방향 90도
const last = _lastProjPos.get(pr.id);
const dx = pr.x - last.x, dy = pr.y - last.y;
const d = Math.hypot(dx, dy);
const nx = -dy / d, ny = dx / d;
const sign = trail.points.length % 2 === 0 ? 1 : -1;
const drift = 24 * (0.85 + Math.random() * 0.3);
trail.points.push({
x: pr.x, y: pr.y, born: now,
dx: nx * drift * sign,
dy: ny * drift * sign,
});
trail.lastSpawnT = now;
}
}
}
// 매 프레임 step: 모든 점이 perpendicular drift
function step(dt) {
for (const trail of rocketTrails.values()) {
for (const p of trail.points) {
p.x += p.dx * dt;
p.y += p.dy * dt;
}
// 0.9초 지난 점 제거
while (trail.points.length && performance.now() - trail.points[0].born >= 900) {
trail.points.shift();
}
}
}
// 매 프레임 draw: 로켓 head + 모든 점을 연결
function drawTrails(ctx, projectiles) {
for (const [id, trail] of rocketTrails) {
const head = projectiles.find(pr => pr.id === id);
const path = head ? [{x:head.x, y:head.y, age:0}] : [];
for (let i = trail.points.length - 1; i >= 0; i--) {
const p = trail.points[i];
path.push({x:p.x, y:p.y, age:performance.now() - p.born});
}
// segment별 alpha + 두께
for (let i = 0; i < path.length - 1; i++) {
const a = path[i], b = path[i+1];
const alpha = 1 - Math.max(a.age, b.age) / 900;
// 외곽 글로우 stroke
// 코어 stroke
}
}
}
결과: 한 연결된 라인이 로켓에서 뻗어 나오고, 점들이 시간 지나며 좌우 교대로 살짝 드리프트해 wavy 곡선이 되어 페이드아웃.
레일건 나선
Q3A 레일건의 시그니처는 빔 주변의 spiral. 2D 탑다운에선 진짜 3D 헬릭스가 안 보이지만, perpendicular 방향 sine 파형으로 helix-viewed-from-side 느낌을 냅니다:
case 'rail': {
const dx = f.x1 - f.x0, dy = f.y1 - f.y0;
const len = Math.hypot(dx, dy);
const ux = dx/len, uy = dy/len; // unit axis
const nx = -uy, ny = ux; // unit perpendicular
// 코어 레이저
ctx.strokeStyle = '#33ff99'; ctx.lineWidth = 3; ctx.beginPath();
ctx.moveTo(f.x0, f.y0); ctx.lineTo(f.x1, f.y1); ctx.stroke();
// 두 가닥 helix (phase π 차이로 교차)
const amp = 2.5 + (26 - 2.5) * age; // 시간 따라 amplitude 확장
const turns = Math.max(4, Math.floor(len / 55));
const segs = Math.min(120, Math.floor(len / 8));
for (let strand = 0; strand < 2; strand++) {
const phase = strand * Math.PI;
ctx.beginPath();
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const ang = t * turns * Math.PI * 2 + phase + age * 6;
const off = Math.sin(ang) * amp;
const px = f.x0 + dx * t + nx * off;
const py = f.y0 + dy * t + ny * off;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
}
}
amplitude가 시간 따라 2.5 → 26px로 커지면서 spin도 함께 회전 → 빔 주변 spiral이 "외부로 흩어지듯 퍼져나가는" 효과.
라이트닝 빔: 매 프레임 jitter
지속 빔은 events가 아니라 player.beam snapshot 필드에서 와요(라이트닝건의 rate(50ms)와 tick(16ms) 불일치로 깜빡임을 방지하기 위해 visual은 매 tick 재계산). 클라 측에서 매 프레임 16개 segment 폴리라인을 jitter로 그려서 살아있는 전류 느낌:
for (let i = 1; i < segs; i++) {
const t = i / segs;
const off = (Math.sin(now * 0.07 + i * 1.7 + seed) + (Math.random() - 0.5) * 1.4) * 11;
pts.push({ x: x0 + dx*t + nx*off, y: y0 + dy*t + ny*off });
}
3겹 stroke (cyan halo + cyan mid + white core) + 끝점 radial glow + 가끔 가지치는 fork.