미니게임 (로그라이크 성장)
무기를 고르고 움직이는 플레이어가 몰려오는 몬스터를 자동 공격하며, 경험치 보석을 먹고 카드로 성장하는 생존형 로그라이크 미니게임입니다.
동작
미니게임 (로그라이크 성장) 실행 화면
- 공격력 2
- 공속 0.42초
- 사거리 220
- 이동속도 170
- 체력 100/100
- 피해감소 0%
- 자석 78
- 경험치 +0%
- 재생 0%
- 언데드 0
검, 화살, 마법, 소환 중 하나의 무기를 선택하고 전투를 시작합니다. 플레이어를 드래그해 움직이면 몬스터가 계속 따라오고, 가장 가까운 몬스터를 자동으로 공격합니다. 몬스터를 처치하면 경험치 보석이 떨어지고, 보석에 가까이 가면 자석 범위 안에서 끌려옵니다. 경험치가 가득 차면 게임을 잠시 멈추고 희귀도가 붙은 성장 카드 3장을 보여줍니다. 카드를 선택하면 공격력, 체력, 자석 범위, 액티브 스킬 같은 능력이 올라가고 다음 스테이지로 이어집니다.
예제 코드
실시간 생존형 로그라이크 게임
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
class RoguelikeMiniGame extends StatefulWidget {
const RoguelikeMiniGame({super.key});
@override
State<RoguelikeMiniGame> createState() => _RoguelikeMiniGameState();
}
class Weapon {
const Weapon(this.name, this.damage, this.cooldown, this.projectileSpeed);
final String name;
final double damage;
final double cooldown;
final double projectileSpeed;
}
class Enemy {
Enemy({required this.position, required this.hp, required this.speed});
Offset position;
double hp;
double speed;
double radius = 14;
}
class Bullet {
Bullet({required this.position, required this.velocity, required this.damage});
Offset position;
Offset velocity;
double damage;
double life = 1.4;
}
class ExpGem {
ExpGem(this.position, this.value);
Offset position;
double value;
}
class UpgradeCard {
const UpgradeCard(this.name, this.kind, this.rarity, this.value);
final String name;
final String kind;
final String rarity;
final int value;
}
class _RoguelikeMiniGameState extends State<RoguelikeMiniGame> {
final random = Random();
final boardSize = const Size(360, 360);
final weapons = const [
Weapon('검', 18, 0.42, 360),
Weapon('화살', 14, 0.30, 470),
Weapon('소환', 10, 0.52, 300),
];
final upgradeNames = const ['공격력 강화', '체력 단련', '자석 감각', '연쇄 번개', '회전 오브'];
final rarities = const ['일반', '레어', '에픽'];
Timer? timer;
late Weapon weapon = weapons.first;
Offset player = const Offset(180, 180);
Offset targetMove = const Offset(180, 180);
List<Enemy> enemies = [];
List<Bullet> bullets = [];
List<ExpGem> gems = [];
List<UpgradeCard> cards = [];
int level = 1;
int stage = 1;
double time = 0;
double spawnTimer = 0;
double attackTimer = 0;
double exp = 0;
double expNeed = 8;
double hp = 100;
double maxHp = 100;
double damageBonus = 0;
double magnet = 78;
bool playing = false;
@override
void dispose() {
timer?.cancel();
super.dispose();
}
void startGame([Weapon? selected]) {
timer?.cancel();
setState(() {
weapon = selected ?? weapon;
player = const Offset(180, 180);
targetMove = player;
enemies = List.generate(5, (_) => createEnemy());
bullets = [];
gems = [];
cards = [];
level = 1;
stage = 1;
time = 0;
spawnTimer = 0;
attackTimer = 0;
exp = 0;
expNeed = 8;
hp = 100;
maxHp = 100;
damageBonus = 0;
magnet = 78;
playing = true;
});
timer = Timer.periodic(const Duration(milliseconds: 16), (_) => tick(0.016));
}
Enemy createEnemy() {
final side = random.nextInt(4);
final x = side == 0 ? -20.0 : side == 1 ? boardSize.width + 20 : random.nextDouble() * boardSize.width;
final y = side == 2 ? -20.0 : side == 3 ? boardSize.height + 20 : random.nextDouble() * boardSize.height;
return Enemy(
position: Offset(x, y),
hp: 26 + stage * 5,
speed: 34 + stage * 3 + random.nextDouble() * 20,
);
}
void tick(double dt) {
if (!playing || cards.isNotEmpty) return;
setState(() {
time += dt;
stage = max(1, (time ~/ 18) + 1);
spawnTimer -= dt;
attackTimer -= dt;
movePlayer(dt);
moveEnemies(dt);
moveBullets(dt);
collectGems(dt);
autoAttack();
if (spawnTimer <= 0) {
enemies.add(createEnemy());
spawnTimer = max(0.35, 1.15 - stage * 0.06);
}
if (hp <= 0) {
playing = false;
timer?.cancel();
}
});
}
void movePlayer(double dt) {
final delta = targetMove - player;
final distance = delta.distance;
if (distance < 2) return;
final step = min(distance, 170 * dt);
player += Offset(delta.dx / distance * step, delta.dy / distance * step);
}
void moveEnemies(double dt) {
for (final enemy in enemies) {
final delta = player - enemy.position;
final distance = max(1, delta.distance);
enemy.position += Offset(delta.dx / distance, delta.dy / distance) * enemy.speed * dt;
if (distance < enemy.radius + 16) {
hp -= 16 * dt;
}
}
}
void moveBullets(double dt) {
for (final bullet in bullets) {
bullet.position += bullet.velocity * dt;
bullet.life -= dt;
for (final enemy in enemies) {
if (enemy.hp <= 0) continue;
if ((enemy.position - bullet.position).distance < enemy.radius + 5) {
enemy.hp -= bullet.damage;
bullet.life = 0;
if (enemy.hp <= 0) {
gems.add(ExpGem(enemy.position, 2 + stage * 0.5));
}
break;
}
}
}
bullets.removeWhere((bullet) => bullet.life <= 0);
enemies.removeWhere((enemy) => enemy.hp <= 0);
}
void collectGems(double dt) {
for (final gem in gems) {
final delta = player - gem.position;
final distance = delta.distance;
if (distance < 16) {
exp += gem.value;
gem.value = 0;
} else if (distance < magnet) {
gem.position += Offset(delta.dx / distance, delta.dy / distance) * 240 * dt;
}
}
gems.removeWhere((gem) => gem.value <= 0);
if (exp >= expNeed) {
exp -= expNeed;
cards = buildCards();
}
}
void autoAttack() {
if (attackTimer > 0 || enemies.isEmpty) return;
enemies.sort((a, b) {
final ad = (a.position - player).distance;
final bd = (b.position - player).distance;
return ad.compareTo(bd);
});
final target = enemies.first;
final delta = target.position - player;
final distance = max(1, delta.distance);
bullets.add(Bullet(
position: player,
velocity: Offset(delta.dx / distance, delta.dy / distance) * weapon.projectileSpeed,
damage: weapon.damage + damageBonus,
));
attackTimer = weapon.cooldown;
}
List<UpgradeCard> buildCards() {
final names = [...upgradeNames]..shuffle(random);
return names.take(3).map((name) {
final rarity = rarities[random.nextInt(rarities.length)];
final value = rarity == '에픽' ? 3 : rarity == '레어' ? 2 : 1;
final kind = name.contains('번개') || name.contains('오브') ? '액티브' : '패시브';
return UpgradeCard(name, kind, rarity, value);
}).toList();
}
void pickCard(UpgradeCard card) {
setState(() {
if (card.name.contains('공격')) damageBonus += card.value * 5;
if (card.name.contains('체력')) {
maxHp += card.value * 20;
hp = maxHp;
}
if (card.name.contains('자석')) magnet += card.value * 24;
if (card.name.contains('번개') || card.name.contains('오브')) damageBonus += card.value * 3;
level += 1;
expNeed += 5;
cards = [];
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
children: weapons.map((item) {
return ChoiceChip(
label: Text(item.name),
selected: weapon.name == item.name,
onSelected: (_) => startGame(item),
);
}).toList(),
),
const SizedBox(height: 12),
Text('Stage ' + stage.toString() + ' · Lv ' + level.toString() + ' · 무기 ' + weapon.name),
LinearProgressIndicator(value: exp / expNeed),
const SizedBox(height: 12),
GestureDetector(
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final local = box.globalToLocal(details.globalPosition);
targetMove = Offset(
local.dx.clamp(0, boardSize.width),
local.dy.clamp(0, boardSize.height),
);
},
onTapDown: (details) {
targetMove = Offset(
details.localPosition.dx.clamp(0, boardSize.width),
details.localPosition.dy.clamp(0, boardSize.height),
);
},
child: SizedBox(
width: boardSize.width,
height: boardSize.height,
child: CustomPaint(
painter: RoguePainter(
player: player,
enemies: enemies,
bullets: bullets,
gems: gems,
hpRatio: hp / maxHp,
magnet: magnet,
),
),
),
),
const SizedBox(height: 12),
if (!playing)
FilledButton(onPressed: () => startGame(), child: const Text('게임 시작')),
if (cards.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: cards.map((card) {
return OutlinedButton(
onPressed: () => pickCard(card),
child: Text(card.rarity + ' · ' + card.kind + ' · ' + card.name + ' +' + card.value.toString()),
);
}).toList(),
),
],
);
}
}
class RoguePainter extends CustomPainter {
const RoguePainter({
required this.player,
required this.enemies,
required this.bullets,
required this.gems,
required this.hpRatio,
required this.magnet,
});
final Offset player;
final List<Enemy> enemies;
final List<Bullet> bullets;
final List<ExpGem> gems;
final double hpRatio;
final double magnet;
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()
..shader = const LinearGradient(
colors: [Color(0xFF142132), Color(0xFF172019), Color(0xFF261922)],
).createShader(Offset.zero & size);
final grid = Paint()..color = Colors.white.withOpacity(0.06)..strokeWidth = 1;
canvas.drawRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(8)), bg);
for (double x = 0; x <= size.width; x += 36) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), grid);
}
for (double y = 0; y <= size.height; y += 36) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), grid);
}
canvas.drawCircle(player, magnet, Paint()..color = const Color(0x18FFD23F));
for (final gem in gems) {
canvas.drawCircle(gem.position, 6, Paint()..color = const Color(0xFF37D6A1));
}
for (final bullet in bullets) {
canvas.drawCircle(bullet.position, 5, Paint()..color = const Color(0xFFFFD23F));
}
for (final enemy in enemies) {
canvas.drawCircle(enemy.position, enemy.radius, Paint()..color = const Color(0xFFFF647C));
canvas.drawCircle(enemy.position.translate(-4, -4), 3, Paint()..color = Colors.white);
}
canvas.drawCircle(player, 25, Paint()..color = const Color(0x2937D6A1));
canvas.drawCircle(player, 18, Paint()..color = const Color(0xFF37D6A1));
canvas.drawCircle(player, 18, Paint()..style = PaintingStyle.stroke..strokeWidth = 4..color = const Color(0xFFF5F1E7));
canvas.drawCircle(player.translate(-6, -4), 3, Paint()..color = const Color(0xFF101311));
canvas.drawCircle(player.translate(6, -4), 3, Paint()..color = const Color(0xFF101311));
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromCenter(center: player.translate(0, 8), width: 18, height: 8), const Radius.circular(4)),
Paint()..color = const Color(0xFFFF647C),
);
canvas.drawRRect(
RRect.fromRectAndRadius(const Rect.fromLTWH(12, 12, 130, 10), const Radius.circular(99)),
Paint()..color = const Color(0xFFE3EDF5),
);
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(12, 12, 130 * hpRatio.clamp(0, 1), 10), const Radius.circular(99)),
Paint()..color = const Color(0xFF14B8A6),
);
}
@override
bool shouldRepaint(covariant RoguePainter oldDelegate) => true;
}
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFDCE6EF)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'로그라이크 생존',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
children: const [Chip(label: Text('무기 검')), Chip(label: Text('Stage 1')), Chip(label: Text('Lv 1'))],
),
const SizedBox(height: 14),
SizedBox(
width: 320,
height: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearProgressIndicator(value: exp / expNeed),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: jobs.map((job) {
return ChoiceChip(
label: Text(job.weapon),
selected: hero.weapon == job.weapon,
onSelected: (_) => setState(() => hero = job),
);
}).toList(),
),
const SizedBox(height: 12),
Text('공격력 $attackPower · 체력 $hp/$maxHp'),
const SizedBox(height: 12),
FilledButton(
onPressed: attackMonster,
child: Text('${hero.skill} HP $monsterHp'),
),
if (levelUpCards.isNotEmpty)
Wrap(children: levelUpCards.map((card) => OutlinedButton(
onPressed: () => pickCard(card),
child: Text(card.name),
)).toList()),
],
),
),
],
),
)
- 공격력 2
- 공속 0.42초
- 사거리 220
- 이동속도 170
- 체력 100/100
- 피해감소 0%
- 자석 78
- 경험치 +0%
- 재생 0%
- 언데드 0
예제 코드 기능별 설명
코드를 나누어 읽기
import
dart:async은 실시간 게임 루프에, dart:math는 몬스터 스폰 위치와 카드 무작위 선택에 사용합니다. material.dart는 GestureDetector, 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 = [];
게임 루프
Timer.periodic으로 약 16ms마다 tick을 호출합니다. 이 안에서 플레이어 이동, 몬스터 추적, 투사체 이동, 경험치 흡수, 스폰 타이밍을 함께 갱신합니다.
timer = Timer.periodic(
const Duration(milliseconds: 16),
(_) => tick(0.016),
);
자동 공격과 몬스터 추적
몬스터는 플레이어 방향으로 계속 이동하고, 플레이어는 가장 가까운 몬스터를 향해 자동으로 투사체를 발사합니다. 버튼을 누르는 전투가 아니라 계속 움직이는 전투입니다.
final target = enemies.first;
final delta = target.position - player;
bullets.add(Bullet(
position: player,
velocity: direction * weapon.projectileSpeed,
damage: weapon.damage + damageBonus,
));
경험치와 카드 성장
몬스터가 죽으면 경험치 보석을 만들고, 보석은 자석 범위 안에서 플레이어 쪽으로 끌려옵니다. 경험치가 가득 차면 전투를 멈추고 희귀도 카드 3장을 보여줍니다.
if (enemy.hp <= 0) {
gems.add(ExpGem(enemy.position, 2 + stage * 0.5));
}
if (exp >= expNeed) {
cards = buildCards();
}
CustomPainter
게임 화면은 일반 위젯을 많이 쌓기보다 CustomPainter로 한 번에 그립니다. 배경 격자, 플레이어, 몬스터, 투사체, 보석, 체력 바가 같은 캔버스에 그려집니다.
CustomPaint(
painter: RoguePainter(
player: player,
enemies: enemies,
bullets: bullets,
gems: gems,
),
)