고급

미니게임 (로그라이크 성장)

무기를 고르고 움직이는 플레이어가 몰려오는 몬스터를 자동 공격하며, 경험치 보석을 먹고 카드로 성장하는 생존형 로그라이크 미니게임입니다.

동작

미니게임 (로그라이크 성장) 실행 화면

검, 화살, 소환 중 하나의 무기를 선택하고 전투를 시작합니다. 방향키 또는 WASD로 플레이어를 움직이면 카메라가 캐릭터를 따라 이동합니다. 검은 투사체가 아니라 전방 베기 범위로 공격하고, 화살은 날아가는 투사체로 공격합니다. 소환은 플레이어가 직접 공격하지 않고 소환 해골이 몬스터를 추적해 공격합니다. 소환 해골이 캐릭터 공격 범위 밖으로 멀어지면 캐릭터 주변으로 텔레포트하고, 해골끼리 겹치면 서로 밀어내며 자리를 잡습니다. 몬스터를 처치하면 경험치 보석이 떨어지고, 보석에 가까이 가면 자석 범위 안에서 끌려옵니다. 경험치가 가득 차면 게임을 잠시 멈추고 희귀도가 붙은 성장 카드 3장을 보여줍니다. 카드를 선택하면 공격력, 체력, 자석 범위, 액티브 스킬 같은 능력이 올라가고 다음 스테이지로 이어집니다.

예제 코드

실시간 생존형 로그라이크 게임

로그라이크 생존
무기 Stage 1 레벨 1
EXP 0/3
스탯
  • 공격력 2
  • 공속 0.42초
  • 사거리 220
  • 이동속도 170
  • 체력 100/100
  • 피해감소 0%
  • 자석 78
  • 경험치 +0%
  • 재생 0%
  • 언데드 0
무기 선택

시작 무기를 고르면 전투가 시작됩니다.

전투 시작 후 방향키 또는 WASD로 이동합니다.

예제 코드 기능별 설명

코드를 나누어 읽기

import

dart:async은 실시간 게임 루프에, dart:math는 몬스터 스폰 위치와 카드 무작위 선택에 사용합니다. services.dart는 방향키와 WASD 입력을 읽는 데 쓰고, material.dart는 CustomPaint, 버튼과 카드 UI를 제공합니다.

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';

데이터 클래스

Weapon, Enemy, Bullet, ExpGem, UpgradeCard는 게임 안의 주요 대상을 작은 모델로 나눈 것입니다. 이렇게 나누면 이동, 충돌, 성장 로직을 객체별로 읽을 수 있습니다.

class Weapon { ... }
class Enemy { ... }
class Bullet { ... }
class ExpGem { ... }
class UpgradeCard { ... }

상태 변수

player는 캐릭터 위치, enemies는 몬스터 목록, bullets는 투사체 목록, gems는 경험치 보석 목록입니다. level, stage, exp, hp는 화면 상단과 스탯 패널에 표시되는 진행 상태입니다.

Offset player = const Offset(180, 180);
List<Enemy> enemies = [];
List<Bullet> bullets = [];
List<ExpGem> gems = [];

무기 설정

무기는 이름만 다른 버튼이 아니라 공격력, 재사용 대기시간, 탄속, 사거리, 이동속도, 투사체 종류를 가진 설정 객체입니다. 검, 화살, 소환이 같은 게임 루프 안에서 서로 다르게 동작할 수 있는 이유가 이 설정값 때문입니다.

final weapons = {
  '검': Weapon(attack: 18, range: 128, projectile: 'sword'),
  '화살': Weapon(attack: 18, range: 220, projectile: 'arrow'),
  '소환': Weapon(attack: 18, range: 202, projectile: 'summon'),
};

초기화

게임을 시작할 때는 레벨, 경험치, 체력, 몬스터, 투사체, 보석, 카드 선택 상태를 모두 초기화합니다. 이전 판에서 얻은 카드나 투사체가 남아 있으면 새 게임의 밸런스가 깨지므로 시작 함수에서 모든 런타임 상태를 한 번에 되돌립니다.

void startGame() {
  level = 1;
  exp = 0;
  hp = maxHp;
  enemies.clear();
  bullets.clear();
  gems.clear();
}

게임 루프

Timer.periodic으로 약 16ms마다 tick을 호출합니다. 이 안에서 플레이어 이동, 몬스터 추적, 투사체 이동, 경험치 흡수, 스폰 타이밍을 함께 갱신합니다.

timer = Timer.periodic(
  const Duration(milliseconds: 16),
  (_) => tick(0.016),
);

키보드 입력

플레이어 이동은 버튼 클릭이 아니라 방향키와 WASD 입력으로 처리합니다. 눌린 키를 Set에 저장하고, 매 프레임마다 좌우/상하 입력을 합산해 이동 벡터를 만듭니다. 대각선 이동이 더 빨라지지 않도록 벡터 길이로 나누어 정규화합니다.

if (pressedKeys.contains(LogicalKeyboardKey.keyA)) dx -= 1;
if (pressedKeys.contains(LogicalKeyboardKey.keyD)) dx += 1;
final length = sqrt(dx * dx + dy * dy);
player += Offset(dx / length, dy / length) * speed * dt;

카메라와 줌

카메라는 플레이어를 중심으로 따라가며, 사거리 강화로 공격 범위가 화면보다 커질 때는 줌아웃합니다. 줌아웃될 때 공격 범위 원뿐 아니라 캐릭터, 몬스터, 보석, 투사체도 같은 비율로 줄어들어 화면 스케일이 맞습니다.

final zoom = min(1, (view.shortestSide - 34) / (attackRange * 2));
canvas.translate(center.dx, center.dy);
canvas.scale(zoom);

몬스터 생성

몬스터는 화면 안쪽이 아니라 카메라가 보는 영역 바깥쪽에서 생성됩니다. 이렇게 해야 몬스터가 갑자기 플레이어 위에 나타나지 않고, 바깥에서 몰려오는 느낌이 납니다. 스테이지가 오를수록 체력과 속도도 함께 올라갑니다.

Enemy spawnEnemy() {
  final side = random.nextInt(4);
  return Enemy(position: outsideCamera(side), hp: 26 + stage * 5);
}

몬스터 추적과 접촉 피해

몬스터는 매 프레임 플레이어 방향으로 이동합니다. 플레이어와 몬스터의 거리가 반지름 합보다 가까워지면 접촉 피해를 줍니다. 피해 감소 카드가 있으면 실제 체력 감소량을 줄입니다.

final direction = (player - enemy.position).normalize();
enemy.position += direction * enemy.speed * dt;
if (distance < enemy.radius + playerRadius) hp -= enemy.damage * (1 - damageReduction) * dt;

재생 회복

재생 카드는 regen 값을 올리는 패시브입니다. 이 값은 표시용으로만 두면 효과가 없으므로 게임 루프에서 매 프레임 체력을 회복해야 합니다. 최대 체력을 넘지 않도록 min으로 제한하고, 체력이 0 이하일 때는 회복하지 않습니다.

if (regen > 0 && hp > 0 && hp < maxHp) {
  hp = min(maxHp, hp + maxHp * regen * dt);
}

피해 처리와 경험치 드롭

damageEnemy는 몬스터 체력을 줄이고, 체력이 0 이하가 되면 경험치 보석을 떨어뜨립니다. 같은 몬스터가 여러 번 죽음 처리되지 않도록 dropped 같은 플래그를 둘 수 있습니다.

void damageEnemy(Enemy enemy, double amount) {
  enemy.hp -= amount;
  if (enemy.hp <= 0) gems.add(ExpGem(enemy.position, 2 + stage));
}

가장 가까운 적 찾기

자동 공격은 아무 몬스터나 공격하지 않고, 공격 범위 안에 있는 가장 가까운 몬스터를 찾습니다. 검과 화살은 이 대상을 기준으로 방향을 잡고, 소환수도 자기 위치에서 가까운 대상을 찾습니다.

Enemy? nearestEnemy(Offset origin, double range) {
  return enemies.where((e) => distance(origin, e.position) <= range).sortByDistance().firstOrNull;
}

무기별 공격 분기

검은 현재 사거리만큼 베기 판정을 만들고, 사거리 강화가 들어오면 공격 반경과 검격 이펙트 길이가 함께 늘어납니다. 화살은 기본적으로 한 발을 쏘지만, 갈래 화살을 얻으면 한 번의 공격에서 여러 방향으로 동시에 발사합니다. 소환 무기에서는 플레이어가 직접 공격하지 않고 해골 소환수가 대신 싸웁니다.

if (weapon.name == '검') {
  slash(radius: weapon.range);
} else if (weapon.name == '화살') {
  for (final angle in splitAngles) {
    bullets.add(ArrowBullet(angle));
  }
}

검 공격 판정

검은 투사체를 만들지 않고 플레이어 앞쪽 부채꼴 또는 직선 범위에 있는 적을 즉시 공격합니다. 검격 방향 카드가 늘어나면 앞, 뒤, 좌, 우 순서로 공격 방향이 추가되고 4방향이 되면 더 이상 해당 카드가 등장하지 않습니다.

for (final angle in swordAngles.take(swordDirections)) {
  if (projection > 16 && projection < swordRange && side < enemy.radius + 16) {
    damageEnemy(enemy, attack * 1.35);
  }
}

화살 투사체

화살은 위치, 속도, 피해량, 남은 수명, 튕김 횟수를 가진 Bullet으로 만들어집니다. 매 프레임 위치를 이동시키고, 적과 충돌하면 피해를 준 뒤 수명을 끝내거나 다음 대상으로 튕깁니다.

bullets.add(Bullet(
  position: player,
  velocity: direction * weapon.speed,
  damage: attackPower,
  bounceLeft: arrowBounce,
));

갈래 화살

갈래 화살은 튕기는 화살과 다릅니다. 튕기는 화살은 맞은 뒤 다음 적으로 방향을 바꾸고, 갈래 화살은 처음 발사할 때 여러 각도로 동시에 나갑니다. 그래서 arrowSplit은 발사 개수를 늘리고, arrowBounce는 충돌 후 전이 횟수를 늘립니다.

final count = 1 + arrowSplit;
for (var i = 0; i < count; i++) {
  final angle = baseAngle + spreadOffset(i, count);
  fireArrow(angle);
}

화살 튕김

화살이 적에게 맞았을 때 bounceLeft가 남아 있으면 주변의 아직 맞지 않은 적을 찾습니다. 다음 대상이 있으면 화살 위치를 충돌 지점으로 옮기고 속도 방향을 새 대상으로 바꿉니다. 튕길 때마다 피해량은 조금 줄어듭니다.

if (bullet.bounceLeft > 0) {
  final next = nearestUnhitEnemy(enemy.position);
  bullet.velocity = directionTo(next) * arrowSpeed;
  bullet.damage *= 0.82;
  bullet.bounceLeft -= 1;
}

소환수 AI

소환 해골은 전사, 마법, 힐러 타입으로 나뉩니다. 전사는 가까이 붙어 베고, 마법 언데드는 대상 위치에 마법진을 만들어 공격하며, 힐러 언데드는 다친 소환수나 플레이어에게 회복 마법을 사용합니다. 범위 밖으로 멀어지면 플레이어 주변으로 텔레포트하고, 소환수끼리 가까워지면 서로 밀어내 겹쳐 보이지 않게 합니다.

if (minion.type == MinionType.mage) {
  spawnMagicCircle(target.position);
} else if (minion.type == MinionType.healer) {
  heal(lowestHpAlly);
}

소환수 텔레포트

소환수가 플레이어 공격 범위보다 멀어지면 직접 걸어오지 않고 플레이어 근처로 텔레포트합니다. 넓은 맵에서 소환수가 화면 밖에 남아 전투에 참여하지 못하는 문제를 막기 위한 처리입니다.

if (distance(minion.position, player) > playerAttackRange) {
  minion.position = player + randomOffset();
  effects.add(TeleportEffect(minion.position));
}

소환수 겹침 방지

여러 소환수가 같은 위치에 몰리면 덜덜 떨리거나 하나로 겹쳐 보입니다. 가까운 소환수끼리는 서로 밀어내는 회피 벡터를 만들고, 이동 속도를 보간해 갑자기 방향이 튀지 않도록 합니다.

final avoid = minionAvoidance(minion);
minion.velocity = lerp(minion.velocity, desiredVelocity + avoid, 0.28);
if (minion.velocity.distance < 3) minion.velocity = Offset.zero;

힐러 회복 대상

힐러는 적을 공격하지 않고 체력이 낮은 아군을 찾습니다. 다친 소환수가 있으면 소환수를 먼저 회복하고, 없으면 플레이어 체력을 회복합니다. 회복할 대상이 없을 때는 플레이어를 따라다닙니다.

final healTarget = injuredMinions.firstOrNull ?? (hp < maxHp ? player : null);
if (healTarget != null) heal(healTarget);

마법 언데드 공격

마법 언데드는 전사처럼 베기 이펙트를 쓰지 않고, 대상 위치에 마법진을 생성해 피해를 줍니다. 이펙트 종류를 분리하면 같은 소환수라도 역할이 시각적으로 구분됩니다.

damageEnemy(target, mageDamage);
effects.add(MageCircleEffect(target.position));

경험치와 카드 성장

몬스터가 죽으면 경험치 보석을 만들고, 보석은 자석 범위 안에서 플레이어 쪽으로 끌려옵니다. 화면의 EXP는 소수점이 나오지 않도록 정수로 누적하고 표시합니다.

if (enemy.hp <= 0) {
  gems.add(ExpGem(enemy.position, 2 + stage));
}
exp += gem.value.round();
if (exp >= expNeed) {
  expNeed = (expNeed * 1.35 + 3).floor();
  cards = buildCards();
}

자석 범위

경험치 보석은 플레이어와 가까워지면 자동으로 끌려옵니다. 자석 감각 카드를 얻으면 이 흡수 범위가 넓어져 직접 보석 위로 이동하지 않아도 경험치를 모을 수 있습니다.

if (distance < magnetRange) {
  gem.position += directionToPlayer * 240 * dt;
}

레벨업 처리

경험치가 요구량 이상이 되면 남은 경험치를 보존하고 카드 선택 화면을 띄웁니다. 다음 레벨 요구량은 이전 요구량에 1.35를 곱하고 3을 더해 점점 커지게 합니다.

if (exp >= expNeed) {
  exp -= expNeed;
  expNeed = (expNeed * 1.35 + 3).floor();
  showCards();
}

무기/액티브/패시브 카드

카드 풀은 현재 무기에 맞는 무기 카드와 공통 액티브, 패시브 카드를 섞어 만듭니다. 검격 방향은 최대 4방향까지만 의미가 있으므로 4개가 되면 더 이상 후보에 넣지 않습니다. 화살은 튕김, 속사, 갈래 발사를 따로 성장시키고, 소환은 전사/마법/힐러 언데드 카드가 등장합니다.

final weaponPool = weaponCards[currentWeapon]
  .where((card) => !(card.name == '검격 방향' && swordDirections >= 4));
final pool = [...weaponPool, ...activeCards, ...passiveCards]..shuffle();

카드 희귀도

카드는 일반, 레어, 에픽 같은 희귀도를 가질 수 있고, 희귀도에 따라 증가량이 달라집니다. 같은 이름의 카드라도 value가 높으면 공격력, 사거리, 소환수 수 같은 수치가 더 크게 오릅니다.

final rarity = rarities[random.nextInt(rarities.length)];
return UpgradeCard(name, kind, rarity.name, rarity.value);

카드 적용

카드를 선택하면 이름에 따라 서로 다른 상태를 변경합니다. 공격력 강화는 attackBonus, 사거리 강화는 weapon.range, 연쇄 번개는 lightningLevel, 전사 언데드는 minions 목록을 바꿉니다.

if (card.name.contains('사거리')) weapon.range += 45 * card.value;
if (card.name.contains('번개')) lightningLevel += card.value;
if (card.name.contains('전사')) addMinion(MinionType.warrior);

액티브 스킬

연쇄 번개는 첫 몬스터를 맞힌 뒤, 강화 개수만큼 이전 대상 근처의 다음 몬스터로 전이됩니다. 전이될 때마다 피해가 조금 줄어들고, 이미 맞은 몬스터는 다시 선택하지 않습니다. 회전 오브는 플레이어 주변을 돌며 닿은 몬스터에게 피해를 줍니다.

for (var i = 0; i < 1 + lightningChains; i++) {
  final enemy = previous == null
      ? randomEnemyInRange()
      : nearestEnemyFrom(previous);
  damageEnemy(enemy, damage);
  damage *= 0.78;
  previous = enemy;
}

회전 오브

회전 오브는 플레이어 주변을 일정 반지름으로 돌며 몬스터와 닿을 때 피해를 줍니다. 오브 수가 늘어나면 각도를 나눠 여러 개가 균등하게 배치됩니다.

for (var i = 0; i < orbCount; i++) {
  final angle = orbAngle + pi * 2 * i / orbCount;
  final orbPosition = player + Offset(cos(angle), sin(angle)) * 52;
  damageEnemiesTouching(orbPosition);
}

이펙트 목록

검격, 번개, 오브, 마법진, 힐, 텔레포트 같은 짧은 시각 효과는 effects 목록에 저장합니다. 각 효과는 life 값을 가지고 있고, 매 프레임 life가 줄어 0이 되면 제거됩니다.

effects.add(GameEffect(type: 'lightning', position: enemy.position, life: 0.18));
effects.removeWhere((effect) => effect.life <= 0);

CustomPainter

게임 화면은 일반 위젯을 많이 쌓기보다 CustomPainter로 한 번에 그립니다. 사거리 강화로 공격 범위 원이 화면보다 커질 수 있으므로, 공격 범위 지름이 보드 안에 들어오도록 카메라 줌을 낮춥니다. 줌아웃될 때 캐릭터, 몬스터, 소환수, 보석, 화살 크기도 같은 비율로 줄어듭니다.

final zoom = min(
  1,
  (view.shortestSide - 34) / (attackRange * 2),
);
canvas.scale(zoom);
drawEntity(scale: zoom);

렌더링 순서

게임 화면은 배경 그리드, 공격 범위, 경험치 보석, 소환수, 투사체, 이펙트, 몬스터, 플레이어 순서로 그립니다. 순서가 꼬이면 몬스터가 이펙트 뒤에 가려지거나, 플레이어가 공격 범위보다 아래에 그려질 수 있습니다.

drawGrid();
drawAttackRange();
drawGems();
drawMinions();
drawBullets();
drawEffects();
drawEnemies();
drawPlayer();

게임 종료

플레이어 체력이 0 이하가 되면 playing을 false로 바꾸고 타이머를 멈춥니다. 종료 후에는 Stage와 Level을 로그로 남겨 이번 판의 성장 결과를 보여줍니다.

if (hp <= 0) {
  playing = false;
  timer?.cancel();
  message = '게임 종료. Stage $stage, Lv $level';
}