← 블로그 목록 2026-05-19

이펙트 시스템: 로켓 폴리라인 + 레일건 나선

비주얼 이펙트는 게임의 첫인상을 좌우합니다. 같은 데미지여도 멋있게 폭발하는 무기가 더 강해 보이고, 더 잘 쓰입니다. Dogkov Arena의 이펙트는 모두 Canvas 2D로 그려지지만 몇 가지 트릭으로 풍부한 느낌을 냈습니다.

이펙트 시스템 구조

두 가지로 나뉩니다:

왜 트레일이 별도냐: 발사체에서 "연결된" 라인을 그리려면 어떤 점이 어느 발사체 소속인지 알아야 하기 때문입니다.

로켓 트레일 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.

← 무기 픽업 Bot AI →