---
id: wiki-2026-0508-2026-04-26-skybound-enemy-motion
title: Skybound — Enemy Motion, Damage Pressure, and Projectile Visual Pass
category: 10_Wiki/Topics
status: verified
canonical_id: self
aliases: [Enemy Steering, Projectile VFX]
duplicate_of: none
source_trust_level: A
confidence_score: 0.9
verification_status: applied
tags: [skybound, gamedev, enemy-ai, projectile-vfx, pressure-curve]
raw_sources: []
last_reinforced: 2026-05-10
github_commit: pending
tech_stack:
  language: typescript
  framework: phaser3
---

# Skybound — Enemy Motion, Damage Pressure, and Projectile Visual Pass

## 매 한 줄
> **"매 VSL 에서 enemy motion + damage pressure + projectile readability 가 player 의 skill ceiling 의 결정 trio"**. 매 Skybound 의 4-26 pass 는 enemy steering (boid → predict-aim mix), damage tick interval, projectile telegraphing visual 의 동시 tuning.

## 매 핵심

### 매 Enemy Motion Modes
- **Direct chase**: simple seek toward player.
- **Boid**: cohesion + separation + alignment.
- **Predict aim**: lead target by velocity * lookahead.
- **Strafe + fire**: orbit at preferred range, fire projectiles.
- **Charge**: telegraph → dash linear.

### 매 Damage Pressure Variables
- **Tick interval**: 매 contact damage 의 cooldown (e.g. 0.5s) — too fast → unfair, too slow → trivial.
- **Knockback strength**: high knockback → easier kiting.
- **iframe duration**: post-hit invincibility (0.3-0.6s typical).

### 매 Projectile Visual Pass
- 매 telegraph (warning ground decal/laser sight) 의 duration 의 0.3-1s.
- 매 projectile body 의 color contrast (red/orange on blue background).
- 매 trail particle 의 lifespan 짧음 (over-trail 의 vision clutter).

## 💻 패턴

### Steering Mix
```typescript
type SteeringWeights = { seek: number; cohesion: number; separation: number; align: number; };

function steer(enemy: Enemy, neighbors: Enemy[], target: Vec2, w: SteeringWeights): Vec2 {
  const seek    = sub(target, enemy.pos).normalize();
  const cohesion = avgPos(neighbors).sub(enemy.pos).normalize();
  const sep      = neighbors.reduce((acc, n) => {
    const d = enemy.pos.distTo(n.pos);
    if (d < 40 && d > 0) return acc.add(sub(enemy.pos, n.pos).scale(1/d));
    return acc;
  }, vec(0,0)).normalize();
  const align    = avgVel(neighbors).normalize();

  return seek.scale(w.seek)
    .add(cohesion.scale(w.cohesion))
    .add(sep.scale(w.separation))
    .add(align.scale(w.align))
    .normalize();
}
```

### Predict-Aim Projectile
```typescript
function predictAim(shooterPos: Vec2, target: { pos: Vec2; vel: Vec2 }, projSpeed: number): Vec2 {
  const toTarget = sub(target.pos, shooterPos);
  const a = target.vel.dot(target.vel) - projSpeed * projSpeed;
  const b = 2 * toTarget.dot(target.vel);
  const c = toTarget.dot(toTarget);
  const disc = b*b - 4*a*c;
  if (disc < 0) return target.pos;
  const t = (-b - Math.sqrt(disc)) / (2*a);
  return target.pos.clone().add(target.vel.clone().scale(Math.max(t, 0)));
}
```

### Damage Tick + iframe
```typescript
class Damageable {
  hp: number;
  iframeUntil = 0;

  takeDamage(amount: number, now: number, iframeSec = 0.4): boolean {
    if (now < this.iframeUntil) return false;
    this.hp -= amount;
    this.iframeUntil = now + iframeSec;
    return true;
  }
}

class ContactDamager {
  lastTickAt = new Map<string, number>();
  intervalSec: number;

  tryDamage(targetId: string, target: Damageable, amount: number, now: number) {
    const last = this.lastTickAt.get(targetId) ?? -Infinity;
    if (now - last < this.intervalSec) return;
    if (target.takeDamage(amount, now)) this.lastTickAt.set(targetId, now);
  }
}
```

### Projectile Telegraph
```typescript
function telegraphLaser(scene, fromX: number, fromY: number, dirRad: number, lengthPx: number, durationMs = 600): Promise<void> {
  return new Promise(resolve => {
    const g = scene.add.graphics();
    g.lineStyle(2, 0xff3333, 0.6);
    g.beginPath();
    g.moveTo(fromX, fromY);
    g.lineTo(fromX + Math.cos(dirRad) * lengthPx, fromY + Math.sin(dirRad) * lengthPx);
    g.strokePath();
    scene.tweens.add({
      targets: g, alpha: 0, duration: durationMs,
      onComplete: () => { g.destroy(); resolve(); }
    });
  });
}

async function fireTelegraphedLaser(scene, from, dir, len) {
  await telegraphLaser(scene, from.x, from.y, dir, len, 600);
  spawnLaserHitbox(scene, from, dir, len);
}
```

### Projectile Visual (high contrast)
```typescript
function makeProjectile(scene, pos, vel) {
  const proj = scene.physics.add.sprite(pos.x, pos.y, 'proj-core');
  proj.setTint(0xffaa33); // warm color on cool background
  proj.setVelocity(vel.x, vel.y);
  scene.add.particles(0, 0, 'spark', {
    follow: proj, lifespan: 150, speed: 0, scale: { start: 0.6, end: 0 }, tint: 0xff8800,
  });
  return proj;
}
```

## 매 결정 기준
| 상황 | Approach |
|---|---|
| Enemy motion feels mob-like | Boid mix (sep > cohesion) |
| Player one-shot dies | Increase tick interval + iframe |
| Projectile invisible | Tint contrast + brief telegraph |
| Predict-aim too punishing | Lookahead 시간 short (0.3s) |

**기본값**: 매 boid+seek mix, 0.4s iframe, 0.5s contact tick, 0.6s telegraph.

## 🔗 Graph

## 🤖 LLM 활용
**언제**: steering weight tuning, telegraph duration enumeration, VFX color spec.
**언제 X**: per-frame perf profiling.

## ❌ 안티패턴
- **No telegraph on fast projectile**: unfair — 0.3s+ telegraph 의 사용.
- **iframe 0**: chain-stun death — minimum 0.3s.
- **Pure seek (no boid)**: stacking — separation 의 사용.
- **Projectile same color as background**: visibility 망 — high-contrast tint.

## 🧪 검증 / 중복
- Verified (Reynolds boid paper, bullet-hell genre conventions).
- 신뢰도 A.

## 🕓 Changelog
| 날짜 | 변경 |
|---|---|
| 2026-05-08 | Phase 1 |
| 2026-05-10 | Manual cleanup — steering + damage pressure + projectile VFX 정리 |
