CONTENT
ここから
この章でやること
この章では、まずアプリを動かします。
コードを最初から理解しようとしなくて大丈夫です。
まずはコピペして、画面に人生ゲームが表示されるところまで進めます。
コードを貼る
↓
必要なパッケージを入れる
↓
flutter run
↓
人生ゲームが動く
今日のゴール
この章のゴールは、これです。
サイコロを振れる状態まで動かす
画面が出て、下のボタンから「サイコロを振る」が押せれば成功です。
まずここだけやる
読むのが大変な人は、ここだけ見て進めてください。
1. Flutterプロジェクトを作る
2. pubspec.yamlを貼り替える
3. main.dartを貼り替える
4. flutter pub get
5. flutter run
この5つだけです。
Step 1:Flutterプロジェクトを作る
ターミナルを開いて、作業したい場所に移動します。
例です。
cd ~/dev
新しいFlutterプロジェクトを作ります。
flutter create life_game_mvp
作ったフォルダに移動します。
cd life_game_mvp
Step 2:pubspec.yamlを開く
プロジェクトの中にある、次のファイルを開きます。
pubspec.yaml
このファイルは、アプリで使うパッケージを管理する場所です。
今回使う大事なパッケージはこれです。
timelines_plus
人生のマスを、縦のタイムラインとして表示するために使います。
Step 3:pubspec.yamlを貼り替える
pubspec.yaml を、次の内容に貼り替えます。
name: life_game_mvp
description: A simple Life Game MVP using Flutter.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.6.0
dependencies:
flutter:
sdk: flutter
timelines_plus: ^2.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
今回は、教材をシンプルにするために必要最低限にしています。
Step 4:パッケージを読み込む
ターミナルで次のコマンドを実行します。もしくは、pubspec.yamlを編集し、保存すればインストールできます。
flutter pub get
これは、pubspec.yaml に書いたパッケージをダウンロードするコマンドです。
成功すると、だいたいこのような流れになります。
Resolving dependencies...
Downloading packages...
Got dependencies!
Step 5:main.dartを開く
次に、アプリ本体のコードを入れます。
このファイルを開きます。
lib/main.dart
中にあるコードは、いったん全部消してOKです。
そして、今回の人生ゲームアプリの main.dart を丸ごと貼り付けます。
lib/main.dart
↓
全部削除
↓
人生ゲームのコードを全部貼る
↓
保存
main.dartをコピーしましょう。
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:timelines_plus/timelines_plus.dart';
void main() {
runApp(const TimelineLifeGameApp());
}
class TimelineLifeGameApp extends StatelessWidget {
const TimelineLifeGameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Timeline Life Game',
debugShowCheckedModeBanner: false,
theme: ThemeData(
fontFamilyFallback: const <String>[
'YakuHanJPs',
'Roboto',
'Hiragino Kaku Gothic ProN',
'ヒラギノ角ゴ ProN W3',
'Arial',
'ncomJp',
'sans-serif',
],
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.red,
brightness: Brightness.light,
),
scaffoldBackgroundColor: AppColors.white,
useMaterial3: true,
textTheme: const TextTheme(
bodyMedium: TextStyle(
color: AppColors.text,
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.7,
),
titleLarge: TextStyle(
color: AppColors.heading,
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.5,
letterSpacing: 0.48,
),
titleMedium: TextStyle(
color: AppColors.heading,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.5,
letterSpacing: 0.32,
),
labelLarge: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.4,
),
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.white,
surfaceTintColor: AppColors.white,
elevation: 0,
centerTitle: false,
),
),
home: const TimelineLifeGamePage(),
);
}
}
class AppColors {
static const Color red = Color(0xffe60012);
static const Color deepRed = Color(0xffcf0001);
static const Color white = Color(0xffffffff);
static const Color black = Color(0xff000000);
static const Color gray = Color(0xffc8c8c8);
static const Color gray100 = Color(0xfff8f7f6);
static const Color gray300 = Color(0xffe6e6e6);
static const Color gray500 = Color(0xffd9d9d9);
static const Color footer = Color(0xfff2f2f2);
static const Color heading = Color(0xff1b1b1b);
static const Color text = Color(0xff3c3c3c);
static const Color textSub = Color(0xff8c8c8c);
static const Color gold = Color(0xff9d7c00);
static const Color blue = Color(0xff00a5e6);
static const Color yellow = Color(0xfffff000);
static const Color orange = Color(0xfff58c14);
static const Color teal = Color(0xff08b3bf);
static const Color green = Color(0xff28a745);
static const Color purple = Color(0xff7e57c2);
}
enum TileType {
start,
normal,
payday,
event,
property,
tax,
goal,
}
enum GamePhase {
waitingRoll,
rolling,
showingModal,
choosingProperty,
gameOver,
}
enum EventCategory {
income,
expense,
work,
health,
family,
investment,
chance,
trouble,
}
class BoardTile {
const BoardTile({
required this.index,
required this.age,
required this.stage,
required this.label,
required this.description,
required this.type,
this.price = 0,
this.rent = 0,
});
final int index;
final int age;
final String stage;
final String label;
final String description;
final TileType type;
final int price;
final int rent;
}
class PlayerState {
const PlayerState({
required this.id,
required this.name,
required this.color,
required this.position,
required this.cash,
required this.properties,
required this.isFinished,
});
final int id;
final String name;
final Color color;
final int position;
final int cash;
final Set<int> properties;
final bool isFinished;
PlayerState copyWith({
int? position,
int? cash,
Set<int>? properties,
bool? isFinished,
}) {
return PlayerState(
id: id,
name: name,
color: color,
position: position ?? this.position,
cash: cash ?? this.cash,
properties: properties ?? this.properties,
isFinished: isFinished ?? this.isFinished,
);
}
}
class PropertyOwner {
const PropertyOwner({
required this.tileIndex,
required this.ownerPlayerId,
});
final int tileIndex;
final int ownerPlayerId;
}
class GameLog {
const GameLog(this.message);
final String message;
}
class GameResult {
const GameResult({
required this.player,
required this.totalAssets,
});
final PlayerState player;
final int totalAssets;
}
class EventInfo {
const EventInfo({
required this.title,
required this.description,
required this.cashChange,
required this.category,
});
final String title;
final String description;
final int cashChange;
final EventCategory category;
}
class TimelineLifeGamePage extends StatefulWidget {
const TimelineLifeGamePage({super.key});
@override
State<TimelineLifeGamePage> createState() => _TimelineLifeGamePageState();
}
class _TimelineLifeGamePageState extends State<TimelineLifeGamePage>
with TickerProviderStateMixin {
final Random _random = Random();
late List<BoardTile> _tiles;
late List<PlayerState> _players;
late List<PropertyOwner> _propertyOwners;
late List<GameLog> _logs;
late List<EventInfo> _eventPool;
late AnimationController _dicePulseController;
late AnimationController _turnController;
late ScrollController _timelineScrollController;
Timer? _diceShuffleTimer;
int _currentPlayerIndex = 0;
int _lastDice = 1;
int _visibleDice = 1;
GamePhase _phase = GamePhase.waitingRoll;
BoardTile? _pendingPropertyTile;
@override
void initState() {
super.initState();
_dicePulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
lowerBound: 0.97,
upperBound: 1.03,
)..repeat(reverse: true);
_turnController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..forward();
_timelineScrollController = ScrollController();
_resetGame();
}
@override
void dispose() {
_diceShuffleTimer?.cancel();
_dicePulseController.dispose();
_turnController.dispose();
_timelineScrollController.dispose();
super.dispose();
}
void _resetGame() {
_diceShuffleTimer?.cancel();
_tiles = _createTiles();
_players = _createInitialPlayers();
_propertyOwners = <PropertyOwner>[];
_eventPool = _createEventPool();
_logs = <GameLog>[
const GameLog('ゲーム開始。サイコロを振って人生を進めましょう。'),
];
_currentPlayerIndex = 0;
_lastDice = 1;
_visibleDice = 1;
_phase = GamePhase.waitingRoll;
_pendingPropertyTile = null;
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_timelineScrollController.hasClients) {
_timelineScrollController.jumpTo(0);
}
});
}
List<BoardTile> _createTiles() {
return const <BoardTile>[
BoardTile(
index: 0,
age: 20,
stage: 'スタート',
label: '人生のはじまり',
description: 'ここから人生ゲームが始まります。サイコロを振って、仕事・お金・資産形成の道を進みます。',
type: TileType.start,
),
BoardTile(
index: 1,
age: 21,
stage: '学生・準備期',
label: '準備の日々',
description: '大きな収支はありません。これからの人生に向けて、少しずつ経験を積みます。',
type: TileType.normal,
),
BoardTile(
index: 2,
age: 23,
stage: '社会人スタート期',
label: '小さなカフェ',
description: '小さなカフェに投資できます。所有すると、他のプレイヤーが止まった時に通行料を得られます。',
type: TileType.property,
price: 300,
rent: 80,
),
BoardTile(
index: 3,
age: 24,
stage: '変化の時期',
label: 'ライフイベント',
description: '人生には予想外の出来事がつきものです。収入が増えることも、出費が増えることもあります。',
type: TileType.event,
),
BoardTile(
index: 4,
age: 25,
stage: '初任給・収入期',
label: '初任給',
description: '仕事の成果として給料を受け取ります。現金が増え、次の選択肢が広がります。',
type: TileType.payday,
),
BoardTile(
index: 5,
age: 27,
stage: '副業・挑戦期',
label: 'ネットショップ',
description: 'ネットショップに投資できます。小さく始められる資産形成の第一歩です。',
type: TileType.property,
price: 420,
rent: 120,
),
BoardTile(
index: 6,
age: 28,
stage: '社会の現実期',
label: '税金・支払い',
description: '収入が増えると、税金や固定費も発生します。現金が減ります。',
type: TileType.tax,
),
BoardTile(
index: 7,
age: 30,
stage: '安定期',
label: '日常の積み重ね',
description: '大きな出来事はありません。人生には、こうした安定した時間も大切です。',
type: TileType.normal,
),
BoardTile(
index: 8,
age: 32,
stage: '資産形成期',
label: '地方マンション',
description: '地方マンションに投資できます。収益性は中程度ですが、安定した資産になります。',
type: TileType.property,
price: 550,
rent: 160,
),
BoardTile(
index: 9,
age: 34,
stage: '転機',
label: 'ライフイベント',
description: '仕事・家族・健康・投資など、人生の転機となる出来事が発生します。',
type: TileType.event,
),
BoardTile(
index: 10,
age: 36,
stage: 'キャリアアップ期',
label: '昇給',
description: 'これまでの努力が評価され、収入が増えます。現金が増えます。',
type: TileType.payday,
),
BoardTile(
index: 11,
age: 38,
stage: '事業拡大期',
label: 'コワーキングスペース',
description: 'コワーキングスペースに投資できます。人が集まる場所は、将来の収益源になります。',
type: TileType.property,
price: 700,
rent: 220,
),
BoardTile(
index: 12,
age: 40,
stage: '中盤の安定期',
label: '生活の見直し',
description: '収入・支出・時間の使い方を見直す時期です。大きな収支はありません。',
type: TileType.normal,
),
BoardTile(
index: 13,
age: 43,
stage: '資産拡張期',
label: '駅前テナント',
description: '駅前テナントに投資できます。購入価格は上がりますが、通行料も高くなります。',
type: TileType.property,
price: 850,
rent: 280,
),
BoardTile(
index: 14,
age: 46,
stage: '人生の分岐点',
label: 'ライフイベント',
description: '大きなチャンスやトラブルが起きる可能性があります。現金が増減します。',
type: TileType.event,
),
BoardTile(
index: 15,
age: 49,
stage: '責任増加期',
label: '税金・維持費',
description: '生活や資産が大きくなると、維持費や税金も増えます。現金が減ります。',
type: TileType.tax,
),
BoardTile(
index: 16,
age: 52,
stage: '成熟期',
label: '駅前ビル',
description: '大きな資産となる駅前ビルに投資できます。高額ですが、高い通行料を得られます。',
type: TileType.property,
price: 1000,
rent: 360,
),
BoardTile(
index: 17,
age: 56,
stage: '振り返り期',
label: '穏やかな日',
description: '大きな出来事はありません。これまでの選択を振り返る時間です。',
type: TileType.normal,
),
BoardTile(
index: 18,
age: 60,
stage: '成果回収期',
label: '退職金・成果報酬',
description: '長く積み重ねてきた成果として、まとまった収入を受け取ります。',
type: TileType.payday,
),
BoardTile(
index: 19,
age: 65,
stage: 'ゴール',
label: '人生の集計',
description: 'ゴールです。現金と所有資産を合計し、最終順位を決定します。',
type: TileType.goal,
),
];
}
List<PlayerState> _createInitialPlayers() {
return <PlayerState>[
PlayerState(
id: 0,
name: 'Player 1',
color: AppColors.red,
position: 0,
cash: 1500,
properties: <int>{},
isFinished: false,
),
PlayerState(
id: 1,
name: 'Player 2',
color: AppColors.blue,
position: 0,
cash: 1500,
properties: <int>{},
isFinished: false,
),
];
}
List<EventInfo> _createEventPool() {
return const <EventInfo>[
EventInfo(
title: '副業が大成功',
description: '週末に始めた小さな副業が話題になりました。思わぬ収入です。',
cashChange: 300,
category: EventCategory.work,
),
EventInfo(
title: '急な出費',
description: '家電が突然故障。生活には欠かせないので買い替えました。',
cashChange: -200,
category: EventCategory.expense,
),
EventInfo(
title: '投資リターン',
description: '以前から積み立てていた投資が少しだけ伸びました。',
cashChange: 450,
category: EventCategory.investment,
),
EventInfo(
title: '修理費発生',
description: '車のメンテナンス費用が発生しました。安全第一です。',
cashChange: -350,
category: EventCategory.trouble,
),
EventInfo(
title: '臨時収入',
description: '昔申し込んでいたキャンペーンに当選しました。',
cashChange: 150,
category: EventCategory.income,
),
EventInfo(
title: '小さな出費',
description: '友人との食事会に参加。楽しい時間でしたが少し出費しました。',
cashChange: -100,
category: EventCategory.expense,
),
EventInfo(
title: 'ボーナス支給',
description: '日頃の努力が評価され、会社から特別ボーナスが支給されました。',
cashChange: 500,
category: EventCategory.work,
),
EventInfo(
title: '資格試験に合格',
description: 'スキルアップに成功。報奨金を受け取りました。',
cashChange: 250,
category: EventCategory.work,
),
EventInfo(
title: 'スマホ紛失',
description: '移動中にスマホを紛失。買い替え費用が発生しました。',
cashChange: -300,
category: EventCategory.trouble,
),
EventInfo(
title: 'フリマで売却',
description: '使っていないものを整理して販売。ちょっとした収入になりました。',
cashChange: 180,
category: EventCategory.income,
),
EventInfo(
title: '健康診断で再検査',
description: '念のため再検査を受けました。検査費用がかかりました。',
cashChange: -180,
category: EventCategory.health,
),
EventInfo(
title: '動画がバズる',
description: '投稿した動画が思った以上に伸び、広告収益が入りました。',
cashChange: 600,
category: EventCategory.chance,
),
EventInfo(
title: '旅行に行く',
description: '気分転換の旅行へ。思い出は増えましたが財布は軽くなりました。',
cashChange: -400,
category: EventCategory.family,
),
EventInfo(
title: '紹介料を獲得',
description: '知人を紹介したことで紹介料を受け取りました。',
cashChange: 350,
category: EventCategory.income,
),
EventInfo(
title: 'パソコン買い替え',
description: '仕事効率を上げるために新しいパソコンを購入しました。',
cashChange: -500,
category: EventCategory.work,
),
EventInfo(
title: 'イベント登壇',
description: '小さなイベントに登壇し、謝礼を受け取りました。',
cashChange: 280,
category: EventCategory.work,
),
EventInfo(
title: '保険の見直し',
description: '固定費の見直しに成功。節約分が手元に残りました。',
cashChange: 220,
category: EventCategory.chance,
),
EventInfo(
title: '引っ越し費用',
description: '住環境を整えるために引っ越し。まとまった費用がかかりました。',
cashChange: -450,
category: EventCategory.family,
),
EventInfo(
title: '講師依頼',
description: '得意分野を教える機会があり、講師料を受け取りました。',
cashChange: 400,
category: EventCategory.work,
),
EventInfo(
title: '医療費',
description: '体調を崩して通院。健康の大切さを実感しました。',
cashChange: -220,
category: EventCategory.health,
),
EventInfo(
title: '古い株が上昇',
description: '昔少しだけ買っていた株が上がりました。',
cashChange: 520,
category: EventCategory.investment,
),
EventInfo(
title: '税金の追加支払い',
description: '見落としていた税金の支払いが発生しました。',
cashChange: -380,
category: EventCategory.expense,
),
EventInfo(
title: 'コンテスト入賞',
description: '応募していた企画が評価され、賞金を獲得しました。',
cashChange: 480,
category: EventCategory.chance,
),
EventInfo(
title: '家具を購入',
description: '生活を整えるために家具を新調しました。',
cashChange: -260,
category: EventCategory.expense,
),
EventInfo(
title: 'ポイント還元',
description: '貯まっていたポイントを現金同等で活用できました。',
cashChange: 120,
category: EventCategory.income,
),
EventInfo(
title: '仕事道具の故障',
description: '大切な仕事道具が故障。修理費がかかりました。',
cashChange: -320,
category: EventCategory.trouble,
),
EventInfo(
title: '長期契約を獲得',
description: '継続案件が決まり、まとまった収入が入りました。',
cashChange: 700,
category: EventCategory.work,
),
EventInfo(
title: '勉強代',
description: '将来のために講座へ参加。今は出費ですが、未来への投資です。',
cashChange: -300,
category: EventCategory.work,
),
EventInfo(
title: '家族からお祝い',
description: '節目のお祝いとして、家族からお金を受け取りました。',
cashChange: 200,
category: EventCategory.family,
),
EventInfo(
title: '財布を落とす',
description: '不注意で財布を落としてしまいました。かなり痛い出費です。',
cashChange: -420,
category: EventCategory.trouble,
),
];
}
PlayerState get _currentPlayer => _players[_currentPlayerIndex];
Future<void> _rollDiceAndMove() async {
if (_phase != GamePhase.waitingRoll) {
return;
}
final PlayerState player = _currentPlayer;
if (player.isFinished) {
_moveToNextPlayer();
return;
}
_startDiceShuffle();
setState(() {
_phase = GamePhase.rolling;
});
await Future<void>.delayed(const Duration(milliseconds: 750));
_stopDiceShuffle();
final int dice = _random.nextInt(6) + 1;
final int nextPosition = min(player.position + dice, _tiles.length - 1);
final BoardTile landedTile = _tiles[nextPosition];
_lastDice = dice;
_visibleDice = dice;
_players[_currentPlayerIndex] = player.copyWith(position: nextPosition);
_addLog(
'${player.name} が $dice を出しました。${landedTile.age}歳「${landedTile.label}」に進みました。',
);
setState(() {});
_scrollToTile(nextPosition);
await Future<void>.delayed(const Duration(milliseconds: 240));
await _handleTileEffect(landedTile);
}
void _startDiceShuffle() {
_diceShuffleTimer?.cancel();
_diceShuffleTimer = Timer.periodic(
const Duration(milliseconds: 80),
(_) {
if (!mounted) {
return;
}
setState(() {
_visibleDice = _random.nextInt(6) + 1;
});
},
);
}
void _stopDiceShuffle() {
_diceShuffleTimer?.cancel();
_diceShuffleTimer = null;
}
void _scrollToTile(int tileIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_timelineScrollController.hasClients) {
return;
}
final double maxScroll =
_timelineScrollController.position.maxScrollExtent;
final double targetOffset = min(
maxScroll,
max(0, tileIndex * 100.0 - 100.0),
);
_timelineScrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOutCubic,
);
});
}
Future<void> _handleTileEffect(BoardTile tile) async {
switch (tile.type) {
case TileType.start:
await _showLifeEventModal(
title: 'START',
categoryLabel: 'LIFE START',
subtitle: '${_currentPlayer.name} は人生のスタート地点にいます。',
description: tile.description,
icon: Icons.flag,
accentColor: AppColors.green,
);
_addCashToCurrentPlayer(300);
_addLog('${_currentPlayer.name} はスタートボーナス 300 を受け取りました。');
_moveToNextPlayer();
case TileType.normal:
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'NORMAL',
subtitle: '${tile.age}歳|${tile.stage}',
description: _normalMessage(),
icon: Icons.circle_outlined,
accentColor: AppColors.heading,
);
_addLog('通常マスです。大きな収支はありません。');
_moveToNextPlayer();
case TileType.payday:
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'PAYDAY',
subtitle: '${tile.age}歳|${tile.stage}',
description: tile.description,
icon: Icons.payments,
accentColor: AppColors.green,
cashChange: 500,
);
_addCashToCurrentPlayer(500);
_addLog('${_currentPlayer.name} は ${tile.label} で 500 を受け取りました。');
_moveToNextPlayer();
case TileType.event:
final EventInfo event = _eventPool[_random.nextInt(_eventPool.length)];
final Color eventColor = _eventColor(event.category, event.cashChange);
await _showLifeEventModal(
title: event.title,
categoryLabel: _eventCategoryLabel(event.category),
subtitle: '${tile.age}歳|${tile.stage}',
description: event.description,
icon: _eventCategoryIcon(event.category),
accentColor: eventColor,
cashChange: event.cashChange,
);
_applyEvent(event);
_moveToNextPlayer();
case TileType.tax:
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'TAX',
subtitle: '${tile.age}歳|${tile.stage}',
description: tile.description,
icon: Icons.receipt_long,
accentColor: AppColors.red,
cashChange: -250,
);
_addCashToCurrentPlayer(-250);
_addLog('${_currentPlayer.name} は ${tile.label} で 250 を支払いました。');
_moveToNextPlayer();
case TileType.property:
await _handlePropertyTile(tile);
case TileType.goal:
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'GOAL',
subtitle: '${tile.age}歳|${tile.stage}',
description: tile.description,
icon: Icons.emoji_events,
accentColor: AppColors.red,
);
_finishCurrentPlayer();
}
_checkGameOver();
}
Future<void> _handlePropertyTile(BoardTile tile) async {
final PropertyOwner? owner = _findOwner(tile.index);
if (owner == null) {
if (_currentPlayer.cash >= tile.price) {
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'ASSET CHANCE',
subtitle: '${tile.age}歳|${tile.stage}',
description:
'${tile.description}\n\n購入価格は ${tile.price}、通行料は ${tile.rent} です。',
icon: Icons.storefront,
accentColor: AppColors.gold,
);
_pendingPropertyTile = tile;
_phase = GamePhase.choosingProperty;
_addLog('${tile.label} は未購入です。価格 ${tile.price} で購入できます。');
setState(() {});
} else {
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'ASSET CHANCE',
subtitle: '${tile.age}歳|${tile.stage}',
description:
'${tile.description}\n\n購入価格は ${tile.price} ですが、現在の所持金では購入できません。',
icon: Icons.storefront,
accentColor: AppColors.gold,
);
_addLog('${_currentPlayer.name} は ${tile.label} を購入する資金が足りません。');
_moveToNextPlayer();
}
return;
}
if (owner.ownerPlayerId == _currentPlayer.id) {
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'MY ASSET',
subtitle: '${tile.age}歳|${tile.stage}',
description: 'ここは自分の資産です。通行料は発生しません。資産を持っている安心感があります。',
icon: Icons.storefront,
accentColor: _currentPlayer.color,
);
_addLog('${tile.label} は自分の資産です。');
_moveToNextPlayer();
return;
}
final PlayerState ownerPlayer = _players.firstWhere(
(PlayerState player) => player.id == owner.ownerPlayerId,
);
await _showLifeEventModal(
title: tile.label,
categoryLabel: 'RENT PAYMENT',
subtitle: '${ownerPlayer.name} が所有している資産です。',
description: '通行料として ${tile.rent} を支払います。所有者の現金が増え、自分の現金が減ります。',
icon: Icons.storefront,
accentColor: ownerPlayer.color,
cashChange: -tile.rent,
);
_payRent(tile, owner.ownerPlayerId);
_moveToNextPlayer();
}
void _buyPendingProperty() {
final BoardTile? tile = _pendingPropertyTile;
if (tile == null) {
return;
}
final PlayerState player = _currentPlayer;
if (player.cash < tile.price) {
_addLog('${player.name} は資金不足で購入できません。');
_pendingPropertyTile = null;
_phase = GamePhase.waitingRoll;
_moveToNextPlayer();
return;
}
final Set<int> updatedProperties = <int>{
...player.properties,
tile.index,
};
_players[_currentPlayerIndex] = player.copyWith(
cash: player.cash - tile.price,
properties: updatedProperties,
);
_propertyOwners.add(
PropertyOwner(
tileIndex: tile.index,
ownerPlayerId: player.id,
),
);
_addLog('${player.name} は ${tile.label} を ${tile.price} で購入しました。');
_pendingPropertyTile = null;
_phase = GamePhase.waitingRoll;
_moveToNextPlayer();
}
void _skipPendingProperty() {
final BoardTile? tile = _pendingPropertyTile;
if (tile != null) {
_addLog('${_currentPlayer.name} は ${tile.label} の購入を見送りました。');
}
_pendingPropertyTile = null;
_phase = GamePhase.waitingRoll;
_moveToNextPlayer();
}
void _payRent(BoardTile tile, int ownerPlayerId) {
final int payerIndex = _currentPlayerIndex;
final int ownerIndex = _players.indexWhere(
(PlayerState player) => player.id == ownerPlayerId,
);
if (ownerIndex < 0) {
return;
}
final PlayerState payer = _players[payerIndex];
final PlayerState owner = _players[ownerIndex];
_players[payerIndex] = payer.copyWith(cash: payer.cash - tile.rent);
_players[ownerIndex] = owner.copyWith(cash: owner.cash + tile.rent);
_addLog(
'${payer.name} は ${owner.name} の ${tile.label} に止まり、通行料 ${tile.rent} を支払いました。',
);
setState(() {});
}
void _applyEvent(EventInfo event) {
_addCashToCurrentPlayer(event.cashChange);
if (event.cashChange >= 0) {
_addLog(
'${_currentPlayer.name} は「${event.title}」で ${event.cashChange} を獲得しました。',
);
} else {
_addLog(
'${_currentPlayer.name} は「${event.title}」で ${event.cashChange.abs()} を支払いました。',
);
}
}
void _addCashToCurrentPlayer(int amount) {
final PlayerState player = _currentPlayer;
_players[_currentPlayerIndex] = player.copyWith(
cash: player.cash + amount,
);
setState(() {});
}
void _finishCurrentPlayer() {
_players[_currentPlayerIndex] = _currentPlayer.copyWith(
position: _tiles.length - 1,
isFinished: true,
);
_addLog('${_currentPlayer.name} がゴールしました。');
_moveToNextPlayer();
}
void _checkGameOver() {
final bool allFinished = _players.every(
(PlayerState player) => player.isFinished,
);
if (allFinished) {
_phase = GamePhase.gameOver;
_addLog('全員がゴールしました。最終資産を集計します。');
setState(() {});
}
}
PropertyOwner? _findOwner(int tileIndex) {
for (final PropertyOwner owner in _propertyOwners) {
if (owner.tileIndex == tileIndex) {
return owner;
}
}
return null;
}
void _moveToNextPlayer() {
if (_players.every((PlayerState player) => player.isFinished)) {
_phase = GamePhase.gameOver;
setState(() {});
return;
}
int nextIndex = _currentPlayerIndex;
for (int i = 0; i < _players.length; i++) {
nextIndex = (nextIndex + 1) % _players.length;
if (!_players[nextIndex].isFinished) {
break;
}
}
_currentPlayerIndex = nextIndex;
_phase = GamePhase.waitingRoll;
_turnController
..reset()
..forward();
_addLog('${_players[_currentPlayerIndex].name} のターンです。');
setState(() {});
}
void _addLog(String message) {
_logs.insert(0, GameLog(message));
if (_logs.length > 8) {
_logs = _logs.take(8).toList();
}
setState(() {});
}
int _calculateTotalAssets(PlayerState player) {
int propertyValue = 0;
for (final int tileIndex in player.properties) {
propertyValue += _tiles[tileIndex].price;
}
return player.cash + propertyValue;
}
List<GameResult> _buildResults() {
final List<GameResult> results = _players
.map(
(PlayerState player) => GameResult(
player: player,
totalAssets: _calculateTotalAssets(player),
),
)
.toList();
results.sort(
(GameResult a, GameResult b) => b.totalAssets.compareTo(a.totalAssets),
);
return results;
}
int _remainingTiles(PlayerState player) {
return max(0, _tiles.length - 1 - player.position);
}
Color _tileColor(TileType type) {
switch (type) {
case TileType.start:
return const Color(0xffe8f7ef);
case TileType.normal:
return AppColors.white;
case TileType.payday:
return const Color(0xffe8f7ef);
case TileType.event:
return const Color(0xfffffbdf);
case TileType.property:
return const Color(0xfffff8dc);
case TileType.tax:
return const Color(0xffffeeee);
case TileType.goal:
return const Color(0xffffeef0);
}
}
IconData _tileIcon(TileType type) {
switch (type) {
case TileType.start:
return Icons.flag;
case TileType.normal:
return Icons.circle_outlined;
case TileType.payday:
return Icons.payments;
case TileType.event:
return Icons.auto_awesome;
case TileType.property:
return Icons.storefront;
case TileType.tax:
return Icons.receipt_long;
case TileType.goal:
return Icons.emoji_events;
}
}
String _normalMessage() {
final List<String> messages = <String>[
'今日は大きな出来事はありません。落ち着いた一日でした。',
'いつも通りの日常です。こういう日も人生には大切です。',
'何も起きませんでしたが、少しだけ経験値が増えた気がします。',
'平穏な時間が流れました。次のチャンスを待ちましょう。',
'特別な収支はありません。次のマスに期待しましょう。',
'今日は準備の日。大きな変化はありませんが、前には進んでいます。',
'静かな一日でした。人生ゲームにも休憩は必要です。',
'大きな出費も収入もなし。財布はそのままです。',
'普通の日でした。けれど、普通の日があるからイベントが輝きます。',
'いつも通りに過ごしました。次のターンへ進みましょう。',
];
return messages[_random.nextInt(messages.length)];
}
Color _eventColor(EventCategory category, int cashChange) {
switch (category) {
case EventCategory.income:
return AppColors.green;
case EventCategory.expense:
return AppColors.red;
case EventCategory.work:
return AppColors.blue;
case EventCategory.health:
return AppColors.orange;
case EventCategory.family:
return AppColors.purple;
case EventCategory.investment:
return AppColors.gold;
case EventCategory.chance:
return AppColors.green;
case EventCategory.trouble:
return AppColors.red;
}
}
IconData _eventCategoryIcon(EventCategory category) {
switch (category) {
case EventCategory.income:
return Icons.add_card;
case EventCategory.expense:
return Icons.money_off;
case EventCategory.work:
return Icons.work;
case EventCategory.health:
return Icons.favorite;
case EventCategory.family:
return Icons.home;
case EventCategory.investment:
return Icons.trending_up;
case EventCategory.chance:
return Icons.auto_awesome;
case EventCategory.trouble:
return Icons.warning_amber;
}
}
String _eventCategoryLabel(EventCategory category) {
switch (category) {
case EventCategory.income:
return 'INCOME';
case EventCategory.expense:
return 'EXPENSE';
case EventCategory.work:
return 'WORK';
case EventCategory.health:
return 'HEALTH';
case EventCategory.family:
return 'FAMILY';
case EventCategory.investment:
return 'INVESTMENT';
case EventCategory.chance:
return 'CHANCE';
case EventCategory.trouble:
return 'TROUBLE';
}
}
Future<void> _showLifeEventModal({
required String title,
required String categoryLabel,
required String subtitle,
required String description,
required IconData icon,
required Color accentColor,
int? cashChange,
}) async {
if (!mounted) {
return;
}
setState(() {
_phase = GamePhase.showingModal;
});
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 18),
child: Container(
constraints: const BoxConstraints(maxWidth: 440),
decoration: const BoxDecoration(
color: AppColors.white,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(18, 18, 18, 14),
decoration: BoxDecoration(
color: AppColors.gray100,
border: Border(
bottom: BorderSide(
color: accentColor.withValues(alpha: 0.35),
),
),
),
child: Row(
children: <Widget>[
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.white,
shape: BoxShape.circle,
border: Border.all(color: accentColor, width: 2),
),
child: Icon(
icon,
color: accentColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
categoryLabel,
style: TextStyle(
color: accentColor,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
height: 1.3,
),
),
const SizedBox(height: 2),
Text(
title,
style: const TextStyle(
color: AppColors.heading,
fontSize: 17,
fontWeight: FontWeight.w600,
height: 1.35,
letterSpacing: 0.4,
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
subtitle,
style: const TextStyle(
color: AppColors.heading,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.6,
letterSpacing: 0.3,
),
),
const SizedBox(height: 8),
Text(
description,
style: const TextStyle(
color: AppColors.text,
fontSize: 12,
height: 1.7,
fontWeight: FontWeight.w400,
),
),
if (cashChange != null) ...<Widget>[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 9,
),
decoration: BoxDecoration(
color: cashChange >= 0
? const Color(0xffe8f7ef)
: const Color(0xffffeeee),
border: Border.all(
color: cashChange >= 0
? AppColors.green
: AppColors.red,
),
),
child: Row(
children: <Widget>[
Icon(
cashChange >= 0
? Icons.trending_up
: Icons.trending_down,
color: cashChange >= 0
? AppColors.green
: AppColors.red,
size: 18,
),
const SizedBox(width: 8),
Text(
cashChange >= 0
? '現金 +$cashChange'
: '現金 -${cashChange.abs()}',
style: TextStyle(
color: cashChange >= 0
? AppColors.green
: AppColors.red,
fontSize: 13,
fontWeight: FontWeight.w700,
height: 1.4,
),
),
],
),
),
],
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
},
style: FilledButton.styleFrom(
backgroundColor: AppColors.red,
foregroundColor: AppColors.white,
minimumSize: const Size(double.infinity, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'OK',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
height: 1.4,
),
),
),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final List<GameResult> results = _buildResults();
return Scaffold(
backgroundColor: AppColors.white,
appBar: _buildAppBar(),
body: SafeArea(
child: Column(
children: <Widget>[
_buildTopTurnBar(results),
_buildRankingBar(results),
Expanded(
child: _buildTimelineArea(),
),
_buildBottomActionBar(results),
],
),
),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
toolbarHeight: 52,
titleSpacing: 14,
title: Row(
children: <Widget>[
Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.red,
borderRadius: BorderRadius.circular(999),
),
child: const Center(
child: Text(
'人生ゲーム',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
height: 1.4,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'タイムライン版',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.heading,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.5,
letterSpacing: 0.3,
),
),
),
],
),
actions: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
onPressed: _resetGame,
icon: const Icon(Icons.refresh),
tooltip: 'リセット',
color: AppColors.red,
iconSize: 20,
),
),
],
bottom: const PreferredSize(
preferredSize: Size.fromHeight(1),
child: Divider(
height: 1,
thickness: 1,
color: AppColors.gray300,
),
),
);
}
Widget _buildTopTurnBar(List<GameResult> results) {
final bool isGameOver = _phase == GamePhase.gameOver;
final String title = isGameOver ? 'ゲーム終了' : '${_currentPlayer.name} のターン';
final int currentAssets = _calculateTotalAssets(_currentPlayer);
final int remaining = _remainingTiles(_currentPlayer);
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: const BoxDecoration(
color: AppColors.white,
),
child: Row(
children: <Widget>[
ScaleTransition(
scale: Tween<double>(begin: 0.97, end: 1.0).animate(
CurvedAnimation(
parent: _turnController,
curve: Curves.easeInOutCubic,
),
),
child: _PlayerPiece(player: _currentPlayer, size: 26),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.heading,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.5,
letterSpacing: 0.3,
),
),
),
_MiniStatus(label: '現金', value: '${_currentPlayer.cash}'),
const SizedBox(width: 4),
_MiniStatus(label: '資産', value: '$currentAssets'),
const SizedBox(width: 4),
_MiniStatus(label: '残り', value: '$remaining'),
if (isGameOver && results.isNotEmpty) ...<Widget>[
const SizedBox(width: 4),
_SmallBadge(
label: 'WIN ${results.first.player.id + 1}',
backgroundColor: AppColors.red,
foregroundColor: AppColors.white,
),
],
],
),
);
}
Widget _buildRankingBar(List<GameResult> results) {
return Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: const BoxDecoration(
color: AppColors.gray100,
),
child: Row(
children: results.asMap().entries.map(
(MapEntry<int, GameResult> entry) {
final int rank = entry.key + 1;
final GameResult result = entry.value;
return Expanded(
child: Container(
margin: EdgeInsets.only(
right: entry.key == results.length - 1 ? 0 : 6,
),
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: const BoxDecoration(
color: AppColors.white,
),
child: Row(
children: <Widget>[
Text(
'$rank位',
style: TextStyle(
color: rank == 1 ? AppColors.red : AppColors.textSub,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1.3,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'${result.player.name} ${result.totalAssets}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.text,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.4,
),
),
),
],
),
),
);
},
).toList(),
),
);
}
Widget _buildTimelineArea() {
return ListView(
controller: _timelineScrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
children: <Widget>[
FixedTimeline(
theme: TimelineThemeData(
direction: Axis.vertical,
nodePosition: 0.075,
color: AppColors.red,
indicatorTheme: const IndicatorThemeData(size: 28),
connectorTheme: const ConnectorThemeData(
thickness: 2.4,
color: AppColors.gray300,
),
),
children: List<Widget>.generate(
_tiles.length,
(int index) {
final BoardTile tile = _tiles[index];
return _buildTimelineTile(tile);
},
),
),
],
);
}
Widget _buildTimelineTile(BoardTile tile) {
final bool isCurrentTile = tile.index == _currentPlayer.position;
final bool isPassedByAnyPlayer = _players.any(
(PlayerState player) => player.position >= tile.index,
);
final List<PlayerState> playersOnTile = _players
.where((PlayerState player) => player.position == tile.index)
.toList();
return TimelineTile(
mainAxisExtent: isCurrentTile ? 108 : 96,
node: TimelineNode.simple(
color: isCurrentTile ? AppColors.red : AppColors.gray300,
lineThickness: isCurrentTile ? 3.2 : 2.4,
indicatorSize: isCurrentTile ? 36 : 28,
drawStartConnector: tile.index != 0,
drawEndConnector: tile.index != _tiles.length - 1,
indicatorChild: Container(
decoration: BoxDecoration(
color: isCurrentTile ? AppColors.red : AppColors.white,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${tile.age}',
style: TextStyle(
color: isCurrentTile ? AppColors.white : AppColors.textSub,
fontSize: isCurrentTile ? 10 : 9,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
),
),
contents: Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: _TimelineTileCard(
tile: tile,
icon: _tileIcon(tile.type),
backgroundColor: _tileColor(tile.type),
isCurrentTile: isCurrentTile,
isPassed: isPassedByAnyPlayer,
owner: _findOwner(tile.index),
players: _players,
playersOnTile: playersOnTile,
),
),
);
}
Widget _buildBottomActionBar(List<GameResult> results) {
final bool canRoll = _phase == GamePhase.waitingRoll;
final bool isRolling = _phase == GamePhase.rolling;
final bool isChoosingProperty = _phase == GamePhase.choosingProperty;
final bool isGameOver = _phase == GamePhase.gameOver;
final bool isShowingModal = _phase == GamePhase.showingModal;
return Container(
height: 92,
padding: const EdgeInsets.fromLTRB(12, 9, 12, 10),
decoration: const BoxDecoration(
color: AppColors.white,
border: Border(
top: BorderSide(color: AppColors.gray300),
),
),
child: Row(
children: <Widget>[
ScaleTransition(
scale: _dicePulseController,
child: _buildDiceVisual(compact: true),
),
const SizedBox(width: 10),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
child: isGameOver
? _buildGameOverActions(results)
: isChoosingProperty
? _buildPropertyActions()
: _buildDiceActions(
canRoll: canRoll && !isShowingModal,
isRolling: isRolling || isShowingModal,
),
),
),
],
),
);
}
Widget _buildDiceActions({
required bool canRoll,
required bool isRolling,
}) {
return Row(
key: const ValueKey<String>('dice-actions'),
children: <Widget>[
Expanded(
child: FilledButton.icon(
onPressed: canRoll ? _rollDiceAndMove : null,
icon: const Icon(Icons.casino, size: 18),
label: Text(isRolling ? 'サイコロ中...' : 'サイコロを振る'),
style: FilledButton.styleFrom(
minimumSize: const Size(44, 46),
backgroundColor: AppColors.red,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.gray500,
disabledForegroundColor: AppColors.textSub,
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _resetGame,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('リセット'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 46),
foregroundColor: AppColors.red,
side: const BorderSide(color: AppColors.red, width: 1.3),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
);
}
Widget _buildPropertyActions() {
final BoardTile? tile = _pendingPropertyTile;
if (tile == null) {
return _buildDiceActions(canRoll: false, isRolling: false);
}
return Column(
key: const ValueKey<String>('property-actions'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'${tile.label} を購入しますか? 価格 ${tile.price} / 通行料 ${tile.rent}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.heading,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.3,
),
),
const SizedBox(height: 6),
Expanded(
child: Row(
children: <Widget>[
Expanded(
child: FilledButton.icon(
onPressed: _buyPendingProperty,
icon: const Icon(Icons.storefront, size: 18),
label: const Text('購入する'),
style: FilledButton.styleFrom(
minimumSize: const Size(44, 42),
backgroundColor: AppColors.red,
foregroundColor: AppColors.white,
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _skipPendingProperty,
icon: const Icon(Icons.close, size: 18),
label: const Text('見送る'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 42),
foregroundColor: AppColors.red,
side: const BorderSide(color: AppColors.red, width: 1.3),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
),
],
);
}
Widget _buildGameOverActions(List<GameResult> results) {
final String winnerName = results.isEmpty ? '-' : results.first.player.name;
return Row(
key: const ValueKey<String>('game-over-actions'),
children: <Widget>[
Expanded(
child: Text(
'勝者:$winnerName',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.red,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.5,
letterSpacing: 0.35,
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: _resetGame,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('もう一度'),
style: FilledButton.styleFrom(
minimumSize: const Size(44, 46),
backgroundColor: AppColors.red,
foregroundColor: AppColors.white,
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
);
}
Widget _buildDiceVisual({bool compact = false}) {
final double size = compact ? 54 : 62;
final double fontSize = compact ? 23 : 28;
return AnimatedScale(
scale: _phase == GamePhase.rolling ? 1.06 : 1.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOutCubic,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppColors.heading,
width: 1.8,
),
),
child: Center(
child: Text(
'$_visibleDice',
style: TextStyle(
color: AppColors.heading,
fontSize: fontSize,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
),
);
}
}
class _TimelineTileCard extends StatelessWidget {
const _TimelineTileCard({
required this.tile,
required this.icon,
required this.backgroundColor,
required this.isCurrentTile,
required this.isPassed,
required this.owner,
required this.players,
required this.playersOnTile,
});
final BoardTile tile;
final IconData icon;
final Color backgroundColor;
final bool isCurrentTile;
final bool isPassed;
final PropertyOwner? owner;
final List<PlayerState> players;
final List<PlayerState> playersOnTile;
@override
Widget build(BuildContext context) {
final PlayerState? ownerPlayer = owner == null
? null
: players.firstWhere(
(PlayerState player) => player.id == owner!.ownerPlayerId,
);
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isCurrentTile
? AppColors.red
: ownerPlayer == null
? AppColors.gray300
: ownerPlayer.color,
width: isCurrentTile ? 2.2 : 1.1,
),
),
child: Row(
children: <Widget>[
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isCurrentTile ? AppColors.red : AppColors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isCurrentTile ? AppColors.red : AppColors.gray300,
),
),
child: Icon(
icon,
color: isCurrentTile ? AppColors.white : AppColors.heading,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: _TimelineTileText(
tile: tile,
ownerPlayer: ownerPlayer,
isCurrentTile: isCurrentTile,
),
),
const SizedBox(width: 6),
if (playersOnTile.isNotEmpty)
Row(
children: playersOnTile
.map(
(PlayerState player) => _PlayerPiece(
player: player,
size: 25,
),
)
.toList(),
)
else
_SmallBadge(
label: isPassed ? '通過' : '未到達',
backgroundColor: isPassed ? AppColors.gray300 : AppColors.gray100,
foregroundColor: AppColors.textSub,
),
],
),
);
}
}
class _TimelineTileText extends StatelessWidget {
const _TimelineTileText({
required this.tile,
required this.ownerPlayer,
required this.isCurrentTile,
});
final BoardTile tile;
final PlayerState? ownerPlayer;
final bool isCurrentTile;
@override
Widget build(BuildContext context) {
String subText = tile.description;
if (tile.type == TileType.property) {
subText = '購入価格 ${tile.price} / 通行料 ${tile.rent}';
}
if (ownerPlayer != null) {
subText = '所有者 ${ownerPlayer!.name} / 通行料 ${tile.rent}';
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(
'${tile.age}歳',
style: const TextStyle(
color: AppColors.red,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1.3,
),
),
const SizedBox(width: 5),
Expanded(
child: Text(
tile.stage,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSub,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.3,
),
),
),
if (isCurrentTile)
const _SmallBadge(
label: '現在地',
backgroundColor: AppColors.red,
foregroundColor: AppColors.white,
),
],
),
const SizedBox(height: 2),
Text(
tile.label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.heading,
fontSize: 13,
fontWeight: FontWeight.w700,
height: 1.4,
),
),
const SizedBox(height: 1),
Text(
subText,
overflow: TextOverflow.ellipsis,
maxLines: isCurrentTile ? 2 : 1,
style: const TextStyle(
color: AppColors.text,
fontSize: 10,
height: 1.35,
fontWeight: FontWeight.w400,
),
),
],
);
}
}
class _MiniStatus extends StatelessWidget {
const _MiniStatus({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 7),
decoration: const BoxDecoration(
color: AppColors.gray100,
),
child: Row(
children: <Widget>[
Text(
'$label ',
style: const TextStyle(
color: AppColors.textSub,
fontSize: 9,
fontWeight: FontWeight.w600,
height: 1.3,
),
),
Text(
value,
style: const TextStyle(
color: AppColors.heading,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1.3,
),
),
],
),
);
}
}
class _PlayerPiece extends StatelessWidget {
const _PlayerPiece({
required this.player,
this.size = 22,
});
final PlayerState player;
final double size;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
width: size,
height: size,
margin: const EdgeInsets.only(right: 3),
decoration: BoxDecoration(
color: player.color,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${player.id + 1}',
style: TextStyle(
color: AppColors.white,
fontSize: size * 0.42,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
);
}
}
class _SmallBadge extends StatelessWidget {
const _SmallBadge({
required this.label,
required this.backgroundColor,
required this.foregroundColor,
});
final String label;
final Color backgroundColor;
final Color foregroundColor;
@override
Widget build(BuildContext context) {
return Container(
height: 19,
padding: const EdgeInsets.symmetric(horizontal: 7),
decoration: BoxDecoration(
color: backgroundColor,
),
child: Center(
child: Text(
label,
style: TextStyle(
color: foregroundColor,
fontSize: 9,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
),
);
}
}
pubspec.yaml
name: life_game_mvp
description: A simple Life Game MVP using Flutter and GLB models.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.6.0
dependencies:
flutter:
sdk: flutter
timelines_plus: ^2.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Macの場合は保存です。
command + S
Windowsの場合です。
Ctrl + S
Step 6:アプリを起動する
ターミナルで次を実行します。
flutter run
接続している端末やシミュレーターが複数ある場合は、選択画面が出ることがあります。
まずは、ChromeかiOS SimulatorでOKです。
Chrome
iOS Simulator
Android Emulator
どれか1つで動けば大丈夫です。
Step 7:画面が出たら成功
起動できると、次のような画面が出ます。
人生ゲーム
タイムライン版
Player 1 のターン
現金 1500
資産 1500
残り 19
20歳 人生のはじまり
21歳 準備の日々
23歳 小さなカフェ
...
サイコロを振る
リセット
ここまで来たら、もう成功です。
コードを全部理解していなくても大丈夫です。
「動いた」という体験が、最初の一番大事なゴールです。
Step 8:サイコロを振ってみる
下にあるボタンを押します。
サイコロを振る
すると、サイコロの数字が変わります。
そのあと、プレイヤーが進みます。
Player 1 が 3 を出しました。
24歳「ライフイベント」に進みました。
止まったマスによって、イベント画面が出ます。
Step 9:イベント画面を確認する
イベントマスに止まると、モーダル画面が出ます。
例です。
副業が大成功
現金 +300
または、
急な出費
現金 -200
OKボタンを押すと、次のプレイヤーのターンになります。
Step 10:物件マスを確認する
物件マスに止まると、購入するか選べます。
例です。
小さなカフェ
価格 300
通行料 80
購入する
見送る
購入すると、自分の資産になります。
他のプレイヤーが止まると、通行料が発生します。
ここが人生ゲームらしいポイントです。
Step 11:リセットを試す
画面右上、または下のボタンにあるリセットを押します。
リセット
すると、ゲームが最初から始まります。
Player 1
現金 1500
位置 20歳
物件なし
何度でも試してOKです。
この章で触ったファイル
今回触ったファイルは2つだけです。
| ファイル | 役割 |
|---|---|
pubspec.yaml | 使うパッケージを書く |
lib/main.dart | アプリ本体のコードを書く |
最初はこの2つだけ覚えれば大丈夫です。
この章で使ったコマンド
使ったコマンドは3つです。
flutter create life_game_mvp
flutter pub get
flutter run
意味はこうです。
| コマンド | 意味 |
|---|---|
flutter create | 新しいアプリを作る |
flutter pub get | パッケージを入れる |
flutter run | アプリを起動する |
よくあるエラーと直し方
エラー1:timelines_plusが見つからない
Target of URI doesn't exist:
package:timelines_plus/timelines_plus.dart
原因は、パッケージが入っていないことです。
次を実行します。
flutter pub get
それでも直らない場合は、pubspec.yaml にこれがあるか確認します。
timelines_plus: ^2.0.0
エラー2:インデントが違う
pubspec.yaml は、スペースの位置が大事です。
悪い例です。
dependencies:
flutter:
sdk: flutter
良い例です。
dependencies:
flutter:
sdk: flutter
flutter: の前に、スペースが2つ必要です。
エラー3:main.dartにpubspec.yamlまで貼ってしまった
今回、ユーザーから渡されたコードには、最後に pubspec.yaml の内容も続いていました。
main.dart に貼るのはDartコードだけです。
import 'dart:async';
から始まり、
class _SmallBadge extends StatelessWidget {
...
}
までが main.dart です。
次のような部分は、main.dart に貼りません。
name: life_game_mvp
description: ...
dependencies:
これは pubspec.yaml に貼る内容です。
エラー4:withValuesでエラーが出る
環境によっては、次の部分でエラーが出ることがあります。
accentColor.withValues(alpha: 0.35)
その場合は、次のように変更します。
accentColor.withOpacity(0.35)
古いFlutter環境では withOpacity の方が動きやすいです。
エラー5:画面が真っ白になる
まず、ターミナルに赤いエラーが出ていないか確認します。
よくある原因はこの3つです。
main.dartの貼り付けミス
pubspec.yamlのインデントミス
flutter pub getをしていない
焦らず、上から順番に確認します。
動いたら少しだけ遊んでみよう
動いたら、すぐ次の章に進まなくても大丈夫です。
まずは、3回くらいサイコロを振ってみましょう。
サイコロを振る
↓
イベントを見る
↓
OKを押す
↓
次のプレイヤーに交代
この動きを見ておくと、次の章からコードが理解しやすくなります。
ここで理解できればOK
この章で理解するのは、これだけです。
pubspec.yamlにパッケージを書く
main.dartにアプリのコードを書く
flutter pub getで準備する
flutter runで動かす
コードの中身は、次の章から少しずつ見ます。
やる気を維持するコツ
最初は「動いた」だけで十分です。
プログラミングは、動くものがあると一気に楽しくなります。
動く
↓
少し変える
↓
また動く
↓
仕組みが分かる
この順番で進めると、挫折しにくくなります。
最短作業まとめ
読むのが大変な人は、ここだけ見てください。
flutter create life_game_mvp
cd life_game_mvp
pubspec.yaml にこれを入れます。
dependencies:
flutter:
sdk: flutter
timelines_plus: ^2.0.0
そのあと、lib/main.dart に人生ゲームのコードを貼ります。
最後に実行します。
flutter pub get
flutter run
画面が出て、サイコロを振れたら成功です。
チェックリスト
□ Flutterプロジェクトを作った
□ life_game_mvpフォルダに移動した
□ pubspec.yamlを開いた
□ timelines_plusを追加した
□ flutter pub getを実行した
□ lib/main.dartを開いた
□ もとのコードを消した
□ 人生ゲームのコードを貼った
□ 保存した
□ flutter runを実行した
□ 人生ゲーム画面が表示された
□ サイコロを振れた
□ イベント画面が出た
この章のまとめ
この章では、人生ゲームアプリをまず動かしました。
大事なのは、最初から全部理解することではありません。
コピペして動かす
↓
画面を見る
↓
遊んでみる
↓
あとから中身を理解する
この順番で大丈夫です。
次の章では、pubspec.yaml を確認しながら、パッケージが何のために使われているのかを見ていきます。