TEXTBOOK SECTION / AI LEARNING

まずコピペで動かそう

Flutterアプリケーション開発概論の「人生ゲームを作る。サイコロ処理・イベント分岐・プレイヤー管理・データ設計・アニメーション」より、まずコピペで動かそうを解説。生成AI、AI活用、DX、業務改善を実践しながら学べるオンライン教材です。

2人生ゲームを作る。サイコロ処理・イベント分岐・プレイヤー管理・データ設計・アニメーションFlutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発

OVERVIEW

この節で学べること

概要を表示する
項目内容
教材名Flutterアプリケーション開発概論
人生ゲームを作る。サイコロ処理・イベント分岐・プレイヤー管理・データ設計・アニメーション
まずコピペで動かそう
カテゴリFlutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発
学習内容生成AI、AI活用、DX、業務改善を実践しながら理解するための教材です。

TABLE OF CONTENTS

目次

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 を確認しながら、パッケージが何のために使われているのかを見ていきます。

FAQ

よくある質問

まずコピペで動かそうは医療関係者向けだけの内容ですか。
医療分野の例が含まれる場合もありますが、医療関係者だけに限定した内容ではありません。生成AI、AI活用、DX、業務改善、プロトタイプ開発など、一般的なAI学習の事例として読める内容です。
AI初心者でも読めますか。
はい。AIをこれから学ぶ方、数学が苦手な方、仕事でAIを使いたい方にも読み進めやすいように、教材の章と節の流れに沿って整理しています。
サムネイル画像は必ず表示されますか。
はい。教材にcoverUrlが設定されている場合はその画像を表示し、未設定の場合は代替サムネイル画像を表示します。
Flutterアプリケーション開発概論のほかの章も読めますか。
はい。教材トップから章立てを確認でき、前後の節へもページ下部のナビゲーションから移動できます。