feat(ui): 레트로 UI 시스템 추가

- PressStart2P 픽셀 폰트 추가
- RetroColors: 레트로 RPG 스타일 색상 팔레트
- RetroPanel: 픽셀 테두리 패널 위젯
- RetroButton: 레트로 스타일 버튼
- RetroProgressBar: 픽셀 스타일 진행 바
- PixelBorderPainter: 커스텀 테두리 페인터
This commit is contained in:
JiWoong Sul
2025-12-30 18:30:51 +09:00
parent 2d797502a3
commit 708148c767
8 changed files with 795 additions and 1 deletions

Binary file not shown.

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
/// 레트로 RPG 스타일 색상 팔레트 (8-bit/16-bit 클래식 RPG 느낌)
class RetroColors {
RetroColors._();
// ═══════════════════════════════════════════════════════════════════════
// 메인 UI 컬러 (Main UI Colors)
// ═══════════════════════════════════════════════════════════════════════
/// 골드 (테두리, 강조, 타이틀)
static const Color gold = Color(0xFFD4A84B);
/// 밝은 골드 (호버, 하이라이트)
static const Color goldLight = Color(0xFFE8C97A);
/// 어두운 골드 (눌림 상태)
static const Color goldDark = Color(0xFFB08A3A);
/// 갈색 (프레임, 테두리)
static const Color brown = Color(0xFF8B4513);
/// 크림색 (텍스트 배경, 밝은 패널)
static const Color cream = Color(0xFFF5E6C8);
/// 다크 브라운 (패널 배경)
static const Color darkBrown = Color(0xFF3D2817);
/// 매우 어두운 브라운 (딥 배경)
static const Color deepBrown = Color(0xFF2A1A0F);
// ═══════════════════════════════════════════════════════════════════════
// 상태 바 컬러 (Status Bar Colors)
// ═══════════════════════════════════════════════════════════════════════
/// HP 바 빨간색
static const Color hpRed = Color(0xFFCC3333);
/// HP 바 빨간색 (어두운)
static const Color hpRedDark = Color(0xFF8B2222);
/// MP 바 파란색
static const Color mpBlue = Color(0xFF3366CC);
/// MP 바 파란색 (어두운)
static const Color mpBlueDark = Color(0xFF224488);
/// EXP/성공 초록색
static const Color expGreen = Color(0xFF33CC66);
/// EXP/성공 초록색 (어두운)
static const Color expGreenDark = Color(0xFF228844);
// ═══════════════════════════════════════════════════════════════════════
// ASCII 애니메이션 컬러 (기존 유지)
// ═══════════════════════════════════════════════════════════════════════
/// ASCII 흰색 (캐릭터/몬스터)
static const Color asciiWhite = Color(0xFFFFFFFF);
/// ASCII 시안 (긍정 효과)
static const Color asciiCyan = Color(0xFF00FFFF);
/// ASCII 마젠타 (부정 효과)
static const Color asciiMagenta = Color(0xFFFF00FF);
/// ASCII 노란색 (경고, 중요)
static const Color asciiYellow = Color(0xFFFFFF00);
/// ASCII 초록색 (성공, 회복)
static const Color asciiGreen = Color(0xFF00FF00);
/// ASCII 빨간색 (데미지, 위험)
static const Color asciiRed = Color(0xFFFF0000);
// ═══════════════════════════════════════════════════════════════════════
// 텍스트 컬러 (Text Colors)
// ═══════════════════════════════════════════════════════════════════════
/// 기본 텍스트 (밝은 배경용)
static const Color textDark = Color(0xFF2A1A0F);
/// 기본 텍스트 (어두운 배경용)
static const Color textLight = Color(0xFFF5E6C8);
/// 비활성 텍스트
static const Color textDisabled = Color(0xFF7A6A5A);
// ═══════════════════════════════════════════════════════════════════════
// 버튼 컬러 (Button Colors)
// ═══════════════════════════════════════════════════════════════════════
/// 주요 버튼 배경
static const Color buttonPrimary = Color(0xFF5A4A3A);
/// 주요 버튼 배경 (눌림)
static const Color buttonPrimaryPressed = Color(0xFF3A2A1A);
/// 보조 버튼 배경
static const Color buttonSecondary = Color(0xFF4A3A2A);
/// 보조 버튼 배경 (눌림)
static const Color buttonSecondaryPressed = Color(0xFF2A1A0A);
// ═══════════════════════════════════════════════════════════════════════
// 패널 컬러 (Panel Colors)
// ═══════════════════════════════════════════════════════════════════════
/// 패널 배경 (기본)
static const Color panelBg = Color(0xFF3D2817);
/// 패널 배경 (밝은)
static const Color panelBgLight = Color(0xFF5D4837);
/// 패널 테두리 (외곽, 어두운)
static const Color panelBorderOuter = Color(0xFF1A0F08);
/// 패널 테두리 (내곽, 밝은)
static const Color panelBorderInner = Color(0xFF8B7355);
// ═══════════════════════════════════════════════════════════════════════
// 유틸리티 메서드 (Utility Methods)
// ═══════════════════════════════════════════════════════════════════════
/// 레트로 테마용 ColorScheme 생성
static ColorScheme get colorScheme => ColorScheme.dark(
primary: gold,
onPrimary: textDark,
primaryContainer: goldDark,
onPrimaryContainer: textLight,
secondary: brown,
onSecondary: textLight,
secondaryContainer: darkBrown,
onSecondaryContainer: textLight,
tertiary: cream,
onTertiary: textDark,
tertiaryContainer: panelBgLight,
onTertiaryContainer: textLight,
error: hpRed,
onError: textLight,
surface: deepBrown,
onSurface: textLight,
surfaceContainerHighest: panelBg,
outline: panelBorderOuter,
outlineVariant: panelBorderInner,
);
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// 픽셀 스타일 테두리를 그리는 CustomPainter
/// 8-bit 게임의 UI 프레임 느낌을 재현
class PixelBorderPainter extends CustomPainter {
const PixelBorderPainter({
this.borderWidth = 3.0,
this.outerColor = RetroColors.panelBorderOuter,
this.innerColor = RetroColors.panelBorderInner,
this.fillColor,
});
/// 테두리 두께 (픽셀 단위로 표현)
final double borderWidth;
/// 외곽 테두리 색상 (어두운 색)
final Color outerColor;
/// 내곽 테두리 색상 (밝은 색, 입체감 표현)
final Color innerColor;
/// 배경 채우기 색상 (null이면 투명)
final Color? fillColor;
@override
void paint(Canvas canvas, Size size) {
final outerPaint = Paint()
..color = outerColor
..style = PaintingStyle.fill;
final innerPaint = Paint()
..color = innerColor
..style = PaintingStyle.fill;
// 배경 채우기
if (fillColor != null) {
final bgPaint = Paint()
..color = fillColor!
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(borderWidth, borderWidth,
size.width - borderWidth * 2, size.height - borderWidth * 2),
bgPaint,
);
}
// 외곽 테두리 (상단, 좌측 - 어두운 색으로 깊이감)
// 상단
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, borderWidth),
outerPaint,
);
// 좌측
canvas.drawRect(
Rect.fromLTWH(0, 0, borderWidth, size.height),
outerPaint,
);
// 외곽 테두리 (하단, 우측 - 어두운 색)
// 하단
canvas.drawRect(
Rect.fromLTWH(0, size.height - borderWidth, size.width, borderWidth),
outerPaint,
);
// 우측
canvas.drawRect(
Rect.fromLTWH(size.width - borderWidth, 0, borderWidth, size.height),
outerPaint,
);
// 내곽 하이라이트 (상단, 좌측 내부 - 밝은 색으로 입체감)
// 상단 내부
canvas.drawRect(
Rect.fromLTWH(borderWidth, borderWidth,
size.width - borderWidth * 2, borderWidth * 0.5),
innerPaint,
);
// 좌측 내부
canvas.drawRect(
Rect.fromLTWH(borderWidth, borderWidth,
borderWidth * 0.5, size.height - borderWidth * 2),
innerPaint,
);
}
@override
bool shouldRepaint(covariant PixelBorderPainter oldDelegate) {
return borderWidth != oldDelegate.borderWidth ||
outerColor != oldDelegate.outerColor ||
innerColor != oldDelegate.innerColor ||
fillColor != oldDelegate.fillColor;
}
}
/// 골드 테두리 스타일 (타이틀, 중요 패널용)
class GoldBorderPainter extends PixelBorderPainter {
const GoldBorderPainter({
super.borderWidth = 3.0,
super.fillColor,
}) : super(
outerColor: RetroColors.goldDark,
innerColor: RetroColors.goldLight,
);
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// 레트로 RPG 스타일 버튼
/// 8-bit 게임의 눌림 효과를 재현
class RetroButton extends StatefulWidget {
const RetroButton({
super.key,
required this.child,
this.onPressed,
this.isPrimary = true,
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
});
/// 버튼 내부 컨텐츠
final Widget child;
/// 클릭 콜백 (null이면 비활성화)
final VoidCallback? onPressed;
/// Primary 버튼 여부 (색상 차이)
final bool isPrimary;
/// 내부 패딩
final EdgeInsets padding;
@override
State<RetroButton> createState() => _RetroButtonState();
}
class _RetroButtonState extends State<RetroButton> {
bool _isPressed = false;
bool get _isEnabled => widget.onPressed != null;
Color get _backgroundColor {
if (!_isEnabled) return RetroColors.buttonSecondary.withValues(alpha: 0.5);
if (_isPressed) {
return widget.isPrimary
? RetroColors.buttonPrimaryPressed
: RetroColors.buttonSecondaryPressed;
}
return widget.isPrimary
? RetroColors.buttonPrimary
: RetroColors.buttonSecondary;
}
Color get _borderTopLeft {
if (_isPressed) return RetroColors.panelBorderOuter;
return RetroColors.panelBorderInner;
}
Color get _borderBottomRight {
if (_isPressed) return RetroColors.panelBorderInner;
return RetroColors.panelBorderOuter;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _isEnabled ? (_) => setState(() => _isPressed = true) : null,
onTapUp: _isEnabled ? (_) => setState(() => _isPressed = false) : null,
onTapCancel: _isEnabled ? () => setState(() => _isPressed = false) : null,
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 50),
padding: widget.padding,
decoration: BoxDecoration(
color: _backgroundColor,
border: Border(
top: BorderSide(color: _borderTopLeft, width: 2),
left: BorderSide(color: _borderTopLeft, width: 2),
bottom: BorderSide(color: _borderBottomRight, width: 2),
right: BorderSide(color: _borderBottomRight, width: 2),
),
),
transform: _isPressed
? Matrix4.translationValues(1, 1, 0)
: Matrix4.identity(),
child: DefaultTextStyle(
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: _isEnabled ? RetroColors.textLight : RetroColors.textDisabled,
),
child: widget.child,
),
),
);
}
}
/// 레트로 텍스트 버튼 (간편 생성용)
class RetroTextButton extends StatelessWidget {
const RetroTextButton({
super.key,
required this.text,
this.onPressed,
this.isPrimary = true,
this.icon,
});
final String text;
final VoidCallback? onPressed;
final bool isPrimary;
final IconData? icon;
@override
Widget build(BuildContext context) {
return RetroButton(
onPressed: onPressed,
isPrimary: isPrimary,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: RetroColors.textLight),
const SizedBox(width: 8),
],
Text(text.toUpperCase()),
],
),
);
}
}
/// 레트로 아이콘 버튼
class RetroIconButton extends StatelessWidget {
const RetroIconButton({
super.key,
required this.icon,
this.onPressed,
this.size = 32,
});
final IconData icon;
final VoidCallback? onPressed;
final double size;
@override
Widget build(BuildContext context) {
return RetroButton(
onPressed: onPressed,
padding: EdgeInsets.all(size * 0.25),
child: Icon(icon, size: size * 0.5, color: RetroColors.textLight),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
import 'package:askiineverdie/src/shared/widgets/pixel_border_painter.dart';
/// 레트로 RPG 스타일 패널
/// 8-bit 게임의 UI 프레임 느낌을 재현
class RetroPanel extends StatelessWidget {
const RetroPanel({
super.key,
required this.child,
this.padding = const EdgeInsets.all(12),
this.backgroundColor = RetroColors.panelBg,
this.borderWidth = 3.0,
this.useGoldBorder = false,
this.title,
});
/// 패널 내부 컨텐츠
final Widget child;
/// 내부 패딩
final EdgeInsets padding;
/// 배경 색상
final Color backgroundColor;
/// 테두리 두께
final double borderWidth;
/// 골드 테두리 사용 여부 (중요한 패널에 사용)
final bool useGoldBorder;
/// 패널 타이틀 (상단에 표시)
final String? title;
@override
Widget build(BuildContext context) {
final painter = useGoldBorder
? GoldBorderPainter(borderWidth: borderWidth, fillColor: backgroundColor)
: PixelBorderPainter(borderWidth: borderWidth, fillColor: backgroundColor);
return CustomPaint(
painter: painter,
child: Padding(
padding: EdgeInsets.all(borderWidth).add(padding),
child: title != null
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_PanelTitle(title: title!, useGoldBorder: useGoldBorder),
const SizedBox(height: 8),
Flexible(child: child),
],
)
: child,
),
);
}
}
/// 패널 타이틀 위젯
class _PanelTitle extends StatelessWidget {
const _PanelTitle({required this.title, required this.useGoldBorder});
final String title;
final bool useGoldBorder;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: useGoldBorder
? RetroColors.goldDark.withValues(alpha: 0.3)
: RetroColors.panelBorderOuter.withValues(alpha: 0.5),
border: Border(
bottom: BorderSide(
color: useGoldBorder ? RetroColors.gold : RetroColors.panelBorderInner,
width: 1,
),
),
),
child: Text(
title.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: useGoldBorder ? RetroColors.gold : RetroColors.textLight,
letterSpacing: 1,
),
),
);
}
}
/// 골드 테두리 레트로 패널 (중요한 컨텐츠용)
class RetroGoldPanel extends StatelessWidget {
const RetroGoldPanel({
super.key,
required this.child,
this.padding = const EdgeInsets.all(12),
this.title,
});
final Widget child;
final EdgeInsets padding;
final String? title;
@override
Widget build(BuildContext context) {
return RetroPanel(
useGoldBorder: true,
padding: padding,
title: title,
child: child,
);
}
}

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// 레트로 RPG 스타일 프로그레스 바
/// 세그먼트 스타일로 8-bit 게임 느낌 재현
class RetroProgressBar extends StatelessWidget {
const RetroProgressBar({
super.key,
required this.value,
this.maxValue = 1.0,
this.height = 16,
this.segmentCount = 20,
this.fillColor = RetroColors.expGreen,
this.emptyColor = RetroColors.panelBorderOuter,
this.showSegments = true,
this.label,
this.showPercentage = false,
});
/// 현재 값 (0.0 ~ maxValue)
final double value;
/// 최대 값
final double maxValue;
/// 바 높이
final double height;
/// 세그먼트 개수 (showSegments가 true일 때)
final int segmentCount;
/// 채워진 부분 색상
final Color fillColor;
/// 빈 부분 색상
final Color emptyColor;
/// 세그먼트 표시 여부
final bool showSegments;
/// 레이블 (좌측에 표시)
final String? label;
/// 퍼센트 표시 여부
final bool showPercentage;
double get _percentage => maxValue > 0 ? (value / maxValue).clamp(0.0, 1.0) : 0;
@override
Widget build(BuildContext context) {
return Row(
children: [
if (label != null) ...[
Text(
label!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: height * 0.5,
color: RetroColors.textLight,
),
),
const SizedBox(width: 8),
],
Expanded(
child: Container(
height: height,
decoration: BoxDecoration(
color: emptyColor,
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
),
child: showSegments
? _buildSegmentedBar()
: _buildSolidBar(),
),
),
if (showPercentage) ...[
const SizedBox(width: 8),
SizedBox(
width: 48,
child: Text(
'${(_percentage * 100).toInt()}%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: height * 0.45,
color: RetroColors.textLight,
),
textAlign: TextAlign.right,
),
),
],
],
);
}
Widget _buildSegmentedBar() {
final filledSegments = (_percentage * segmentCount).round();
return LayoutBuilder(
builder: (context, constraints) {
final segmentWidth = constraints.maxWidth / segmentCount;
return Row(
children: List.generate(segmentCount, (index) {
final isFilled = index < filledSegments;
return Container(
width: segmentWidth,
decoration: BoxDecoration(
color: isFilled ? fillColor : emptyColor,
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter.withValues(alpha: 0.5),
width: 1,
)
: BorderSide.none,
),
),
);
}),
);
},
);
}
Widget _buildSolidBar() {
return FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: _percentage,
child: Container(color: fillColor),
);
}
}
/// HP 바 (빨간색)
class RetroHpBar extends StatelessWidget {
const RetroHpBar({
super.key,
required this.current,
required this.max,
this.height = 16,
this.showLabel = true,
this.showValue = false,
});
final int current;
final int max;
final double height;
final bool showLabel;
final bool showValue;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
RetroProgressBar(
value: current.toDouble(),
maxValue: max.toDouble(),
height: height,
fillColor: RetroColors.hpRed,
emptyColor: RetroColors.hpRedDark.withValues(alpha: 0.3),
label: showLabel ? 'HP' : null,
),
if (showValue) ...[
const SizedBox(height: 2),
Text(
'$current / $max',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: height * 0.4,
color: RetroColors.textLight.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
],
);
}
}
/// MP 바 (파란색)
class RetroMpBar extends StatelessWidget {
const RetroMpBar({
super.key,
required this.current,
required this.max,
this.height = 16,
this.showLabel = true,
this.showValue = false,
});
final int current;
final int max;
final double height;
final bool showLabel;
final bool showValue;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
RetroProgressBar(
value: current.toDouble(),
maxValue: max.toDouble(),
height: height,
fillColor: RetroColors.mpBlue,
emptyColor: RetroColors.mpBlueDark.withValues(alpha: 0.3),
label: showLabel ? 'MP' : null,
),
if (showValue) ...[
const SizedBox(height: 2),
Text(
'$current / $max',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: height * 0.4,
color: RetroColors.textLight.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
],
);
}
}
/// EXP 바 (초록색)
class RetroExpBar extends StatelessWidget {
const RetroExpBar({
super.key,
required this.current,
required this.max,
this.height = 12,
this.showLabel = true,
this.showPercentage = true,
});
final int current;
final int max;
final double height;
final bool showLabel;
final bool showPercentage;
@override
Widget build(BuildContext context) {
return RetroProgressBar(
value: current.toDouble(),
maxValue: max.toDouble(),
height: height,
fillColor: RetroColors.expGreen,
emptyColor: RetroColors.expGreenDark.withValues(alpha: 0.3),
label: showLabel ? 'EXP' : null,
showPercentage: showPercentage,
);
}
}

View File

@@ -0,0 +1,8 @@
/// 레트로 RPG 스타일 UI 위젯 모음
/// 8-bit/16-bit 클래식 RPG 느낌의 UI 컴포넌트
library;
export 'pixel_border_painter.dart';
export 'retro_button.dart';
export 'retro_panel.dart';
export 'retro_progress_bar.dart';

View File

@@ -92,8 +92,13 @@ flutter:
# - asset: fonts/TrajanPro_Bold.ttf # - asset: fonts/TrajanPro_Bold.ttf
# weight: 700 # weight: 700
# #
# 커스텀 모노스페이스 폰트 (Canvas ASCII 렌더링용) # 커스텀 폰트
fonts: fonts:
# 모노스페이스 폰트 (ASCII 렌더링용)
- family: JetBrainsMono - family: JetBrainsMono
fonts: fonts:
- asset: assets/fonts/JetBrainsMono-Regular.ttf - asset: assets/fonts/JetBrainsMono-Regular.ttf
# 픽셀 폰트 (레트로 UI용)
- family: PressStart2P
fonts:
- asset: assets/fonts/PressStart2P-Regular.ttf