物理演算で作る印象的なスプラッシュ画面
この記事はFlutter Advent Calendar 2025の12月19日分の記事です。
目次
- はじめに
- この記事の進め方
- Step 0: プロジェクト作成
- Step 1: 1個の点が落ちる
- Step 2: 複数の点が降る
- Step 3: 点を雪の形にする
- Step 4: 回転・横揺れを追加
- Step 5: スプラッシュ画面として完成
- Step 6: 継続的に雪を生成
- 補足説明
はじめに
完成イメージ
最終的にこんなスプラッシュ画面を作ります
ソースコード
完成版のソースコードは以下のGitHubリポジトリで公開しています
リポジトリ: https://github.com/kouki2000/physics_splash_demo
各ステップごとにブランチを切っているので、段階的に見ることができます。
Step1とStep4に物理演算の内容がでてきます。また、Step1は少しボリュームが多いのですべてを理解するより、流し読みを推奨します。その方が読みやすいはず...です!
各ステップでどんなものができあがるかイメージを最初に記載しています。「そんなのいいからソースコード全部見せてくれ」という方はGitHubのmainブランチを参照してください。
この記事の進め方
記事の流れ
Step 0: Flutter プロジェクト作成
Step 1: 1個の点が落ちる ← ここから実装スタート
「物理計算の基本」
Step 2: 複数の点が降る
「たくさん管理する」
Step 3: 点を雪の形にする
「見た目を変える」
Step 4: 回転・横揺れを追加
「動きをリアルに」
Step 5: スプラッシュ画面として完成
「ロゴとテキスト追加」
Step 6: 継続的に雪を生成
「ずっと降り続ける」→ 最初に見せた画面!
Step 0: プロジェクト作成
Flutterのプロジェクトを作成します。
手順
# 1. プロジェクト作成
flutter create physics_splash_demo
cd physics_splash_demo
# 2. 動作確認
flutter run
デフォルトのカウンターアプリが動けば大丈夫です。次に進みましょう。
Step 1: 1個の点が落ちる
ブランチ: step-1-single-particle
このステップのゴール
- 画面に白い点を1個表示
- 重力で点が落ちるのが見える
- 物理演算の基本を理解
画面イメージ:
白い点が1個、重力で落ちていきます。左上には位置と速度が表示されます。
ソースコードを書く前にStep1で使用する物理演算
Step 1では、最もシンプルな物理演算を実装します。以下にStep1で使用する物理演算の表を記載します。
Step 1で使う物理演算一覧
| 分類 | 名称 | 説明 | 方程式 | 変数の意味 |
|---|---|---|---|---|
| 基本要素 | 位置 (Position) | オブジェクトがどこにあるか | p = p + v × dt |
p: 位置, v: 速度, dt: 経過時間 |
| 基本要素 | 速度 (Velocity) | どれだけ速く動いているか | v = v + a × dt |
v: 速度, a: 加速度, dt: 経過時間 |
| 基本要素 | 加速度 (Acceleration) | 速度がどう変化するか | a = F / m |
a: 加速度, F: 力, m: 質量 |
| 力 | 重力 (Gravity) | 下向きの一定の力 | F = (0, g) |
g: 重力加速度(例:100) |
| 境界処理 | リセット | 上に戻す | if (p.y > height) then p.y = 100, v = (0, 0) |
p: 位置, v: 速度, height: 画面の高さ |
毎フレームの計算順序
1. 加速度から速度を計算: v = v + a × dt
2. 速度から位置を計算: p = p + v × dt
3. 加速度をリセット: a = 0
(※ なぜこの順序で計算するのか? → 補足説明を参照)
重力
- 常に下向き(Y軸正の方向)に働く一定の力
- X方向には影響しない(横には動かない)
方程式:
F = (0, g)
Fx = 0 (横方向の力はゼロ)
Fy = g (縦方向に重力加速度)
リセット処理
- 画面下に到達したら上に戻す
- 無限ループで動き続けるための処理
条件:
p.y > height (位置のY座標が画面の高さを超えた)
処理内容:
p.y = 100 (Y座標を上に戻す)
v = (0, 0) (速度をゼロにリセット)
(※ なぜ速度もリセットするのか? → 補足説明を参照)
運動方程式の基礎
ニュートンの運動方程式
F = ma
F: 力 (Force)
m: 質量 (Mass)
a: 加速度 (Acceleration)
変形すると:
a = F / m
加速度 = 力 / 質量
意味:
- 同じ力でも、重いものは加速しにくい
- 同じ力でも、軽いものは加速しやすい
Step 1での扱い:
- 質量
m = 1として扱う(暗黙的) - したがって
a = F / 1 = F
dt(デルタタイム)とは?
dt = 前フレームからの経過時間(秒)
(※ なぜdtが必要なのか? → 補足説明を参照)
dtの値:
- 60FPSの場合:
dt ≈ 0.016秒(1/60秒) - 30FPSの場合:
dt ≈ 0.033秒(1/30秒)
具体的な計算例
初期状態
位置: p = (200, 100)
速度: v = (0, 0)
加速度: a = (0, 0)
重力: F = (0, 100)
dt: 0.016秒(60FPS)
1フレーム目
1. 重力を加える
a = (0, 0) + (0, 100) = (0, 100)
2. 速度を更新
v = (0, 0) + (0, 100) × 0.016 = (0, 1.6)
3. 位置を更新
p = (200, 100) + (0, 1.6) × 0.016 = (200, 100.0256)
4. 加速度をリセット
a = (0, 0)
2フレーム目
1. 重力を加える
a = (0, 0) + (0, 100) = (0, 100)
2. 速度を更新
v = (0, 1.6) + (0, 100) × 0.016 = (0, 3.2)
3. 位置を更新
p = (200, 100.0256) + (0, 3.2) × 0.016 = (200, 100.0768)
4. 加速度をリセット
a = (0, 0)
結果:
- 速度が徐々に増える(1.6 → 3.2 → ...)
- どんどん速く落ちる(加速している!)
実装
フォルダを作成
mkdir lib/core
Vector2Dクラスを作成
ファイル: lib/core/vector2d.dart
import 'dart:math';
/// 2Dベクトルクラス
/// 位置、速度、加速度などを表現
class Vector2D {
double x;
double y;
Vector2D(this.x, this.y);
/// ゼロベクトル
Vector2D.zero() : this(0, 0);
/// ベクトルの加算
Vector2D operator +(Vector2D other) => Vector2D(x + other.x, y + other.y);
/// ベクトルの減算
Vector2D operator -(Vector2D other) => Vector2D(x - other.x, y - other.y);
/// スカラー倍
Vector2D operator *(double scalar) => Vector2D(x * scalar, y * scalar);
/// スカラー除算
Vector2D operator /(double scalar) => Vector2D(x / scalar, y / scalar);
/// ベクトルの長さ
double get length => sqrt(x * x + y * y);
/// ベクトルに別のベクトルを加算(自身を変更)
void add(Vector2D other) {
x += other.x;
y += other.y;
}
@override
String toString() => 'Vector2D($x, $y)';
}
ポイント:
-
+,-,*,/演算子をオーバーロード -
add()メソッドで自身を変更(メモリ効率が良い)
SimpleParticleクラスを作成
ファイル: lib/core/simple_particle.dart
import 'package:flutter/material.dart';
import 'vector2d.dart';
/// 簡単なパーティクル(1個の点)
class SimpleParticle {
Vector2D position; // 位置
Vector2D velocity; // 速度
Vector2D acceleration; // 加速度
SimpleParticle({
required this.position,
}) : velocity = Vector2D.zero(),
acceleration = Vector2D.zero();
/// 物理演算を更新
void update(double dt) {
// 1. 速度に加速度を加える
velocity.add(acceleration * dt);
// 2. 位置に速度を加える
position.add(velocity * dt);
// 3. 加速度をリセット
acceleration = Vector2D.zero();
}
/// 力を加える
void applyForce(Vector2D force) {
acceleration.add(force);
}
/// 画面に描画
void draw(Canvas canvas) {
// 白い円を描画
canvas.drawCircle(
Offset(position.x, position.y),
8, // 半径
Paint()..color = Colors.white,
);
}
}
ポイント:
-
update()が物理演算の心臓部 -
applyForce()で力を加える -
draw()で画面に描画
メイン画面を作成
ファイル: lib/main.dart
import 'package:flutter/material.dart';
import 'core/vector2d.dart';
import 'core/simple_particle.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Physics Splash Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const Step1Screen(),
);
}
}
class Step1Screen extends StatefulWidget {
const Step1Screen({Key? key}) : super(key: key);
@override
State<Step1Screen> createState() => _Step1ScreenState();
}
class _Step1ScreenState extends State<Step1Screen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late SimpleParticle _particle;
DateTime _lastTime = DateTime.now();
@override
void initState() {
super.initState();
// パーティクルを作成(画面中央上部)
_particle = SimpleParticle(
position: Vector2D(200, 100),
);
// 60FPSのアニメーションコントローラー
_controller = AnimationController(
vsync: this,
duration: const Duration(days: 1), // 無限ループ
)..addListener(_onFrame);
_controller.repeat();
}
void _onFrame() {
final now = DateTime.now();
final dt = now.difference(_lastTime).inMicroseconds / 1000000.0;
_lastTime = now;
setState(() {
// 重力を適用
final gravity = Vector2D(0, 100); // 下向き
_particle.applyForce(gravity);
// 更新
_particle.update(dt);
// 画面下に到達したらリセット
if (_particle.position.y > MediaQuery.of(context).size.height) {
_particle.position = Vector2D(200, 100);
_particle.velocity = Vector2D.zero();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A237E), // 深い青
body: Stack(
children: [
// パーティクルを描画
CustomPaint(
painter: ParticlePainter(_particle),
size: Size.infinite,
),
// 情報表示
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Step 1: 1個の点が落ちる',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'位置: (${_particle.position.x.toInt()}, ${_particle.position.y.toInt()})',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
Text(
'速度: (${_particle.velocity.x.toStringAsFixed(1)}, ${_particle.velocity.y.toStringAsFixed(1)})',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
),
],
),
);
}
}
/// パーティクルを描画するPainter
class ParticlePainter extends CustomPainter {
final SimpleParticle particle;
ParticlePainter(this.particle);
@override
void paint(Canvas canvas, Size size) {
particle.draw(canvas);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
ポイント:
-
AnimationControllerで60FPSループ -
_onFrame()で毎フレーム更新 -
CustomPaintで描画 - 画面下に到達したらリセット
アニメーションの仕組み
ここまでのコードで、なぜ点が動いて見えるのか、詳しく解説します。
AnimationControllerは「60回/秒のタイマー」
_controller = AnimationController(
vsync: this,
duration: const Duration(days: 1), // 実質無限
)..addListener(_onFrame); // ← 毎フレーム_onFrame()を呼ぶ
_controller.repeat(); // タイマースタート
何をしている?
- AnimationControllerは60回/秒で動くタイマー
-
addListener(_onFrame)= 「毎フレーム_onFrame()を呼んでね」と登録 -
repeat()= タイマーをスタート
_onFrame()が60回/秒呼ばれる
void _onFrame() {
// 1. 前フレームからの経過時間を計算
final now = DateTime.now();
final dt = now.difference(_lastTime).inMicroseconds / 1000000.0;
_lastTime = now;
// 2. setState()を呼ぶ
setState(() {
// 重力を適用
final gravity = Vector2D(0, 100);
_particle.applyForce(gravity);
// 位置を更新
_particle.update(dt);
});
}
setState()の役割:
- Flutterに「状態が変わったよ!画面を再描画して!」と伝える
- これがないと位置が更新されても画面は動かない
setState()で画面が再描画される
setState(() {
_particle.update(dt); // 位置を更新
});
↓
build()メソッドが自動的に呼ばれる
↓
CustomPaint(
painter: ParticlePainter(_particle), // 新しい位置で描画
)
CustomPaintがパーティクルを描画
class ParticlePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
particle.draw(canvas); // 新しい位置に円を描画
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
// ↑ 常にtrueなので、毎フレーム再描画される
}
全体の流れ(1フレームの処理)
フレーム1(時刻 0.000秒)
↓
① AnimationController
「そろそろ次のフレームだよ!」
↓
② _onFrame()が呼ばれる
dt = 0.016秒を計算
↓
③ setState(() {
重力を加える
_particle.update(0.016)
→ 速度に加速度を加える
→ 位置に速度を加える
})
↓
④ Flutterが「状態が変わった!」と判断
build()を呼ぶ
↓
⑤ CustomPaint → ParticlePainter
新しい位置に円を描画
↓
⑥ 画面に表示 ✨
↓
---約0.016秒待つ---
↓
フレーム2(時刻 0.016秒)
↓
(①に戻って繰り返し...)
これが60回/秒繰り返される = 60FPS = 滑らかなアニメーション!
動作確認
flutter run
確認ポイント:
-
画面表示:
- 深い青の背景
- 白い点が1個表示される
-
動き:
- 点が下に落ちる
- どんどん速くなる(加速している)
- 画面下に到達したら上に戻る
-
情報表示:
- 位置のY座標が増えていく
- 速度のY値が増えていく
Step 1のまとめ
できたこと:
- Vector2Dクラス - 2D座標をまとめて管理
- SimpleParticleクラス - 物理演算の基本
- 重力で落ちる動き - update()とapplyForce()
- 60FPSアニメーション - AnimationController
次のステップ:
- Step 2: 複数の点が降る
- PhysicsEngineクラスで複数のパーティクルを管理
- 雪が降ってる感じになる!
Step 2: 複数の点が降る
ブランチ: step-2-multiple-particles
このステップのゴール
- PhysicsEngineクラスを作成
- 複数のパーティクルをまとめて管理
- 画面にたくさんの点が降る
画面イメージ: 雪が降ってる感じになります!
問題:たくさんの点をどう管理する?
Step 1では1個の点でしたが、雪を降らせるには50個、100個の点が必要です。
解決策:PhysicsEngineクラス
全てのパーティクルを一括管理するクラスを作る!
// PhysicsEngineありの場合
void _onFrame() {
setState(() {
_engine.update(dt, screenSize); // これだけ!
});
}
(※ PhysicsEngineの詳しい役割は → 補足説明を参照)
実装
PhysicsEngineクラスを作成
ファイル: lib/core/physics_engine.dart(新規作成)
import 'package:flutter/material.dart';
import 'simple_particle.dart';
import 'vector2d.dart';
/// 物理演算エンジン
/// 複数のパーティクルを管理
class PhysicsEngine {
final List<SimpleParticle> particles = [];
Vector2D gravity;
PhysicsEngine({
Vector2D? gravity,
}) : gravity = gravity ?? Vector2D(0, 100);
/// パーティクルを追加
void addParticle(SimpleParticle particle) {
particles.add(particle);
}
/// 全てのパーティクルを更新
void update(double dt, Size screenSize) {
for (var particle in particles) {
// 重力を適用
particle.applyForce(gravity);
// 更新
particle.update(dt);
// 画面下に到達したら上に戻す
if (particle.position.y > screenSize.height) {
particle.position.y = -10;
particle.velocity = Vector2D.zero();
}
}
}
/// 全てのパーティクルを描画
void draw(Canvas canvas) {
for (var particle in particles) {
particle.draw(canvas);
}
}
/// パーティクル数を取得
int get particleCount => particles.length;
}
ポイント:
-
particlesリストで全パーティクルを管理 -
update()で一括更新 -
draw()で一括描画
main.dartを更新
ファイル: lib/main.dart(置き換え)
import 'package:flutter/material.dart';
import 'dart:math';
import 'core/vector2d.dart';
import 'core/simple_particle.dart';
import 'core/physics_engine.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Physics Splash Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const Step2Screen(),
);
}
}
class Step2Screen extends StatefulWidget {
const Step2Screen({Key? key}) : super(key: key);
@override
State<Step2Screen> createState() => _Step2ScreenState();
}
class _Step2ScreenState extends State<Step2Screen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late PhysicsEngine _engine;
DateTime _lastTime = DateTime.now();
final Random _random = Random();
bool _initialized = false;
@override
void initState() {
super.initState();
// 物理演算エンジンを作成
_engine = PhysicsEngine(
gravity: Vector2D(0, 100),
);
// 60FPSのアニメーションコントローラー
_controller = AnimationController(
vsync: this,
duration: const Duration(days: 1),
)..addListener(_onFrame);
_controller.repeat();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 初回だけパーティクルを50個追加
if (!_initialized) {
for (int i = 0; i < 50; i++) {
_addRandomParticle();
}
_initialized = true;
}
}
/// ランダムな位置にパーティクルを追加
void _addRandomParticle() {
final screenWidth = MediaQuery.of(context).size.width;
final particle = SimpleParticle(
position: Vector2D(
_random.nextDouble() * screenWidth, // ランダムなX座標
_random.nextDouble() * -200, // 画面上部(少し上から)
),
);
_engine.addParticle(particle);
}
void _onFrame() {
final now = DateTime.now();
final dt = now.difference(_lastTime).inMicroseconds / 1000000.0;
_lastTime = now;
setState(() {
final screenSize = MediaQuery.of(context).size;
_engine.update(dt, screenSize); // Engineに任せるだけ!
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A237E), // 深い青
body: Stack(
children: [
// パーティクルを描画
CustomPaint(
painter: EnginePainter(_engine),
size: Size.infinite,
),
// 情報表示
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Step 2: 複数の点が降る',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'パーティクル数: ${_engine.particleCount}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
Text(
'重力: ${_engine.gravity}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
),
],
),
);
}
}
/// PhysicsEngineを描画するPainter
class EnginePainter extends CustomPainter {
final PhysicsEngine engine;
EnginePainter(this.engine);
@override
void paint(Canvas canvas, Size size) {
engine.draw(canvas); // Engineに任せるだけ!
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
変更点(Step 1との違い):
| Step 1 | Step 2 |
|---|---|
SimpleParticle _particle |
PhysicsEngine _engine |
| 1個のパーティクル | 50個のパーティクル |
_particle.applyForce() |
_engine.update() |
_particle.update() |
(Engineの中で実行) |
ParticlePainter |
EnginePainter |
動作確認
flutter run
確認ポイント:
-
画面表示:
- 深い青の背景
- 白い点が50個表示される
-
動き:
- たくさんの点が降ってくる
- 雪が降ってる感じ!
- 画面下に到達したら上に戻る
-
情報表示:
- パーティクル数: 50
- 重力: Vector2D(0.0, 100.0)
Step 2のまとめ
できたこと:
- PhysicsEngineクラス - 複数のパーティクルを一括管理
- 50個のパーティクル - 雪が降ってる感じ
- 効率的な設計 - コードが短く分かりやすい
学んだこと:
- 複数のオブジェクトをリストで管理
- 一括処理のメリット
- 責務の分離(main.dartがシンプルに)
次のステップ:
- Step 3: 点を雪の形にする
- SnowParticleクラスで雪の結晶を描画
- 6角形の美しい雪!
Step 3: 点を雪の形にする
ブランチ: step-3-snow-shape
このステップのゴール
- SnowParticleクラスを作成
- 6角形の雪の結晶を描画
- よりリアルな雪の表現
画面イメージ: 美しい雪の結晶が降ります!
問題:白い点だと雪に見えない
Step 2では白い点が降りましたが、雪の結晶っぽくないです。
雪らしくするには?
- ただの円 → 雪には見えない
- 6角形の結晶 → 雪っぽい!
解決策:SnowParticleクラス
雪の結晶を描画する専用クラスを作る!
※正直このセクションはコピペで大丈夫です。ただ雪の結晶をソースコードで表現しているだけです。
class SnowParticle extends SimpleParticle {
double size; // 雪の大きさ
@override
void draw(Canvas canvas) {
// 6角形の雪を描画
_drawSnowflake(canvas, position, size);
}
}
実装
SnowParticleクラスを作成
ファイル: lib/core/snow_particle.dart(新規作成)
import 'dart:math';
import 'package:flutter/material.dart';
import 'simple_particle.dart';
import 'vector2d.dart';
/// 雪のパーティクル
class SnowParticle extends SimpleParticle {
double size; // 雪の大きさ
SnowParticle({
required Vector2D position,
this.size = 8.0,
}) : super(position: position);
@override
void draw(Canvas canvas) {
_drawSnowflake(canvas, position, size);
}
/// 雪の結晶を描画
void _drawSnowflake(Canvas canvas, Vector2D pos, double size) {
final paint = Paint()
..color = Colors.white
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final center = Offset(pos.x, pos.y);
// 6本の線を描画(60度ずつ)
for (int i = 0; i < 6; i++) {
final angle = (i * 60) * pi / 180;
// メインの線
final endX = pos.x + cos(angle) * size;
final endY = pos.y + sin(angle) * size;
canvas.drawLine(center, Offset(endX, endY), paint);
// 枝を描画
final branchSize = size * 0.4;
final branchStart = size * 0.6;
final branchX = pos.x + cos(angle) * branchStart;
final branchY = pos.y + sin(angle) * branchStart;
// 左の枝
final leftAngle = angle - pi / 6;
final leftX = branchX + cos(leftAngle) * branchSize;
final leftY = branchY + sin(leftAngle) * branchSize;
canvas.drawLine(Offset(branchX, branchY), Offset(leftX, leftY), paint);
// 右の枝
final rightAngle = angle + pi / 6;
final rightX = branchX + cos(rightAngle) * branchSize;
final rightY = branchY + sin(rightAngle) * branchSize;
canvas.drawLine(Offset(branchX, branchY), Offset(rightX, rightY), paint);
}
}
}
ポイント:
-
SimpleParticleを継承(物理演算はそのまま) -
draw()をオーバーライド(描画だけ変更) - 6本の線 + 枝で雪の結晶を表現
main.dartを更新
変更点:
- import を追加
import 'core/snow_particle.dart'; // 追加
- Step3Screen を追加
home: const Step3Screen(), // Step2Screen → Step3Screen
- _addRandomParticle() を _addRandomSnowParticle() に変更
void _addRandomSnowParticle() {
final screenWidth = MediaQuery.of(context).size.width;
final particle = SnowParticle( // ← SnowParticleに変更
position: Vector2D(
_random.nextDouble() * screenWidth,
_random.nextDouble() * -200,
),
size: 6 + _random.nextDouble() * 6, // ← サイズをランダムに(6〜12)
);
_engine.addParticle(particle);
}
変更点(Step 2との違い):
| Step 2 | Step 3 |
|---|---|
SimpleParticle() |
SnowParticle() |
| サイズ固定 | サイズランダム(6〜12) |
| 白い円 | 6角形の雪 |
Step2Screen |
Step3Screen |
動作確認
flutter run
確認ポイント:
-
画面表示:
- 深い青の背景
- 6角形の雪の結晶が50個
-
動き:
- 雪の結晶が降ってくる
- リアルな雪!
- 大きさがバラバラ(6〜12)
-
見た目:
- 6本の線 + 枝
- 白い線で描画
- 雪っぽい!
Step 3のまとめ
できたこと:
- SnowParticleクラス - 雪の結晶を描画
- 6角形の雪 - リアルな雪の表現
- サイズのバリエーション - 大小の雪が混在
学んだこと:
- クラスの継承(SimpleParticle → SnowParticle)
- メソッドのオーバーライド(draw()だけ変更)
- 数学(三角関数で6角形を描画)
次のステップ:
- Step 4: 回転・横揺れを追加
- より自然な動きに
- 風で揺れる雪!
Step 4: 回転・横揺れを追加
ブランチ: step-4-animation
このステップのゴール
- 雪の結晶が回転しながら落ちる
- 風で横に揺れる動き
- よりリアルな雪の表現
画面イメージ: 自然に舞い落ちる雪!
ソースコードを書く前にStep 4で使用する物理演算
Step 4では、新しい物理演算の概念を追加します。
Step 4で使う物理演算一覧
| 分類 | 名称 | 説明 | 方程式 | 変数の意味 |
|---|---|---|---|---|
| 力 | 風 (Wind) | 横方向に働く力 | F_wind = (sin(t / 2000) × 30, 0) |
t: 時刻(ミリ秒), sin: 正弦波 |
| 合成 | 力の合成 | 複数の力を同時に適用 | F_total = F_gravity + F_wind |
F_total: 合計の力 |
| 回転 | 回転角度 | 雪の向き | rotation = rotation + rotationSpeed × dt |
rotation: 角度, rotationSpeed: 回転速度 |
風の力
横方向に働く力:
F_wind = (sin(t / 2000) × 30, 0)
Fx = sin(t / 2000) × 30 (横方向:-30 〜 30)
Fy = 0 (縦方向:影響なし)
sin波を使う理由:
- 時間とともに滑らかに変化
- -1 〜 1 の範囲で周期的に変化
- × 30 することで -30 〜 30 の力に変換
t / 2000 の意味:
- t: 現在時刻(ミリ秒)
- / 2000: 周期を調整(約2秒で1周期)
- 値が大きいほど、ゆっくり変化
グラフで見ると:
風の強さ
30 | ╱‾‾╲ ╱‾‾╲
| ╱ ╲ ╱ ╲
0 |---╱------╲-╱------╲---
| ╲╱ ╲╱
-30 |
└────────────────────────> 時間
0秒 1秒 2秒 3秒 4秒
力の合成
複数の力を同時に適用:
F_total = F_gravity + F_wind
F_total = (0, 100) + (sin(t / 2000) × 30, 0)
= (sin(t / 2000) × 30, 100)
意味:
- 重力(下向き)+ 風(横向き)= 斜めに落ちる
- 風の強さが時間で変化 → 揺れながら落ちる
例(時刻による変化):
時刻 0秒: F_wind = (0, 0) → F_total = (0, 100) まっすぐ下
時刻 0.5秒: F_wind = (30, 0) → F_total = (30, 100) 右斜め下
時刻 1秒: F_wind = (0, 0) → F_total = (0, 100) まっすぐ下
時刻 1.5秒: F_wind = (-30, 0) → F_total = (-30, 100) 左斜め下
時刻 2秒: F_wind = (0, 0) → F_total = (0, 100) まっすぐ下
(繰り返し...)
回転
毎フレーム角度を更新:
rotation = rotation + rotationSpeed × dt
rotation: 現在の角度(ラジアン)
rotationSpeed: 回転速度(ラジアン/秒)
dt: 経過時間(秒)
回転速度のバリエーション:
rotationSpeed = -1.0 〜 1.0 のランダム
例:
rotationSpeed = 1.0 → 時計回りに速く回転
rotationSpeed = 0.5 → 時計回りにゆっくり回転
rotationSpeed = 0.0 → 回転しない
rotationSpeed = -0.5 → 反時計回りにゆっくり回転
rotationSpeed = -1.0 → 反時計回りに速く回転
具体的な計算例
初期状態:
位置: p = (200, 100)
速度: v = (0, 0)
加速度: a = (0, 0)
重力: F_gravity = (0, 100)
回転角度: rotation = 0
回転速度: rotationSpeed = 1.0
時刻: t = 0ミリ秒
dt: 0.016秒
1フレーム目(時刻 0ミリ秒):
1. 風を計算
F_wind = (sin(0 / 2000) × 30, 0) = (0, 0)
2. 力を合成
F_total = (0, 100) + (0, 0) = (0, 100)
3. 加速度に加える
a = (0, 0) + (0, 100) = (0, 100)
4. 速度を更新
v = (0, 0) + (0, 100) × 0.016 = (0, 1.6)
5. 位置を更新
p = (200, 100) + (0, 1.6) × 0.016 = (200, 100.0256)
6. 回転を更新
rotation = 0 + 1.0 × 0.016 = 0.016
結果: まっすぐ下に落ちる、少し回転
30フレーム目(時刻 500ミリ秒):
1. 風を計算
F_wind = (sin(500 / 2000) × 30, 0)
= (sin(0.25) × 30, 0)
≈ (7.4, 0)
2. 力を合成
F_total = (0, 100) + (7.4, 0) = (7.4, 100)
3. 速度を更新
v = v + (7.4, 100) × 0.016
(横方向の速度も増える!)
結果: 右斜め下に落ちる、回転し続ける
問題:まっすぐ落ちるだけでリアルじゃない
Step 3では雪の形になりましたが、まっすぐ落ちるだけです。
リアルな雪にするには?
- まっすぐ落ちる → リアルじゃない
- 回転しながら落ちる → リアル!
- 風で横揺れする → もっとリアル!
解決策:回転と風を追加
2つの動きを追加する!
-
回転(Rotation)
- 各雪が回転速度を持つ
- 毎フレーム角度が変わる
-
風(Wind)
- 横方向の力を加える
- 左右に揺れる
実装
SnowParticleクラスを更新
ファイル: lib/core/snow_particle.dart
追加する変数:
class SnowParticle extends SimpleParticle {
double size;
double rotation; // 追加:回転角度
double rotationSpeed; // 追加:回転速度
SnowParticle({
required Vector2D position,
this.size = 8.0,
this.rotation = 0.0, // 追加
this.rotationSpeed = 0.0, // 追加
}) : super(position: position);
update()メソッドをオーバーライド:
@override
void update(double dt) {
super.update(dt); // 親クラスの処理を実行
// 回転を更新
rotation += rotationSpeed * dt;
}
draw()メソッドを更新:
@override
void draw(Canvas canvas) {
canvas.save(); // 現在の状態を保存
// 回転の中心に移動
canvas.translate(position.x, position.y);
canvas.rotate(rotation); // 回転
// 回転した状態で雪を描画
_drawSnowflake(canvas, Vector2D(0, 0), size);
canvas.restore(); // 状態を復元
}
main.dartを更新
変更点:
- Step4Screen に変更
home: const Step4Screen(), // Step3Screen → Step4Screen
- _Step4ScreenState に wind 変数を追加:
double _windStrength = 0.0; // 風の強さ
- _addRandomSnowParticle() を更新(回転速度を追加):
void _addRandomSnowParticle() {
final screenWidth = MediaQuery.of(context).size.width;
final particle = SnowParticle(
position: Vector2D(
_random.nextDouble() * screenWidth,
_random.nextDouble() * -200,
),
size: 6 + _random.nextDouble() * 6,
rotationSpeed: -1.0 + _random.nextDouble() * 2.0, // -1.0 〜 1.0
);
_engine.addParticle(particle);
}
- _onFrame() を更新(風を追加):
void _onFrame() {
final now = DateTime.now();
final dt = now.difference(_lastTime).inMicroseconds / 1000000.0;
_lastTime = now;
setState(() {
// 風の強さを時間で変化させる(ゆっくり左右に揺れる)
_windStrength = sin(now.millisecondsSinceEpoch / 2000) * 30;
// 風の力を作成
final wind = Vector2D(_windStrength, 0);
// 全パーティクルに風を適用
for (var particle in _engine.particles) {
particle.applyForce(wind);
}
final screenSize = MediaQuery.of(context).size;
_engine.update(dt, screenSize);
});
}
変更点(Step 3との違い):
| Step 3 | Step 4 |
|---|---|
| 回転なし | 回転あり(rotationSpeed) |
| 風なし | 風あり(sin波で揺れる) |
| まっすぐ落ちる | 横揺れしながら落ちる |
Step3Screen |
Step4Screen |
動作確認
flutter run
確認ポイント:
-
回転:
- 雪の結晶が回転しながら落ちる
- 回転速度がバラバラ(-1.0 〜 1.0)
- 時計回りと反時計回りが混在
-
横揺れ:
- 雪が左右に揺れる
- ゆっくりと風が変化
- sin波でスムーズな動き
-
見た目:
- リアルな雪!
- 自然に舞い落ちる感じ
Step 4のまとめ
できたこと:
- 回転 - 雪が回転しながら落ちる
- 風 - 左右に揺れる動き
- リアルな動き - 自然な雪の表現
学んだこと:
- Canvasの変換(translate、rotate)
- sin波で周期的な動き
- 複数の力の合成(重力 + 風)
次のステップ:
- Step 5: スプラッシュ画面として完成
- ロゴとテキストを追加
- 実用的なスプラッシュ画面!
Step 5: スプラッシュ画面として完成
ブランチ: step-5-splash-screen
このステップのゴール
- ロゴ画像を表示
- アプリ名とバージョンを表示
- フェードイン効果
- 実用的なスプラッシュ画面の完成
画面イメージ: 最初に見せた完成版に近づく!
※Step5まででも十分おしゃれで完成系に近いものになっていると思います。記事の最初に記載した完成版ではないですが、このままでもスプラッシュ画面としては使用できそうなのでセクションのタイトルを「スプラッシュ画面として完成」としました。もちろんStep6を試せば記事の最初に記載したイメージになります。
問題:雪だけでスプラッシュ画面として不完全
Step 4では雪がリアルに降るようになりましたが、スプラッシュ画面としては何もないです。
スプラッシュ画面に必要なもの:
- ロゴ画像
- アプリ名
- バージョン情報
- フェードイン効果(オプション)
解決策:ロゴとテキストを追加
中央にロゴとテキストを配置!
実装
1. ロゴ画像を準備
アセットフォルダを作成:
mkdir -p assets/images
ロゴ画像を配置:
- ファイル名:
logo.png - 場所:
assets/images/logo.png - サイズ: 200x200 程度(推奨)
2. pubspec.yamlを更新
ファイル: pubspec.yaml
flutter:
uses-material-design: true
# アセットを追加
assets:
- assets/images/
保存後、パッケージを取得:
flutter pub get
3. main.dartを更新
変更点:
- Step5Screen に変更
home: const Step5Screen(), // Step4Screen → Step5Screen
- _Step5ScreenState に opacity 変数を追加:
double _opacity = 0.0; // フェードイン用
- initState() でフェードイン開始:
// フェードイン開始
Future.delayed(const Duration(milliseconds: 500), () {
setState(() {
_opacity = 1.0;
});
});
- build() を更新(ロゴとテキストを追加):
中央にロゴとテキストを配置します。
変更点(Step 4との違い):
| Step 4 | Step 5 |
|---|---|
| 雪だけ | 雪 + ロゴ + テキスト |
| フェードインなし | フェードインあり |
| デバッグ情報が大きい | デバッグ情報が小さい |
Step4Screen |
Step5Screen |
動作確認
flutter run
確認ポイント:
-
ロゴ:
- 中央に円形のロゴが表示
- 白い背景
- 影付き
-
テキスト:
- アプリ名が表示
- バージョンが表示
- 白い文字
-
アニメーション:
- ロゴとテキストがフェードイン
- 雪が降り続ける
- リアルな動き
Step 5のまとめ
できたこと:
- ロゴ表示 - 円形のロゴを中央に配置
- テキスト表示 - アプリ名とバージョン
- フェードイン効果 - AnimatedOpacity で滑らかに表示
- 実用的なスプラッシュ画面
学んだこと:
- アセット画像の使い方
- Stack で UI を重ねる
- AnimatedOpacity でアニメーション
- Center で中央配置
次のステップ:
- Step 6: 継続的に雪を生成
- ずっと雪が降り続ける!
Step 6: 継続的に雪を生成
ブランチ: step-6-continuous-generation
このステップのゴール
- 継続的に新しい雪が生成される
- 画面下に消えた雪を削除
- ずっと雪が降り続ける感じ
画面イメージ: 本当の雪のように、次々と降ってくる!
問題:50個がループしてるだけ
Step 5では50個の雪が降りましたが、同じ50個がループしてるだけです。
現状:
50個生成 → 画面下到達 → 上に戻る → また降る
↓
同じ雪がループしてるだけ
理想:
雪が降る → 画面下で消える → 新しい雪が上から生成
↓
ずっと新しい雪が降り続ける!
リアルな雪にするには:
- 同じ雪がループ → 不自然
- 画面下で消える → リアル
- 新しい雪が上から生成 → もっとリアル!
解決策:継続的に生成+削除
2つの処理を追加!
-
タイマーで定期的に雪を追加
- 0.2秒ごとに新しい雪を1個追加
- 最大100個まで
-
画面下に消えた雪を削除
- リセットではなく削除
- メモリ効率が良い
実装
PhysicsEngineクラスを更新
ファイル: lib/core/physics_engine.dart
update()メソッドを変更(削除処理を追加):
/// 全てのパーティクルを更新
void update(double dt, Size screenSize) {
// 削除するパーティクルのリスト
final toRemove = <SimpleParticle>[];
for (var particle in particles) {
// 重力を適用
particle.applyForce(gravity);
// 更新
particle.update(dt);
// 画面下に到達したら削除リストに追加
if (particle.position.y > screenSize.height + 50) {
toRemove.add(particle);
}
}
// 削除
for (var particle in toRemove) {
particles.remove(particle);
}
}
ポイント:
- リセット処理を削除
- 画面下+50pxで削除(完全に見えなくなってから)
-
toRemoveリストで安全に削除
main.dartを更新
ファイル: lib/main.dart
変更点:
- import を追加
import 'dart:async'; // この行を追加
- Step6Screen に変更
home: const Step6Screen(), // Step5Screen → Step6Screen
- _Step6ScreenState に Timer を追加:
Timer? _snowGeneratorTimer; // 追加
static const int maxParticles = 100; // 最大パーティクル数
- initState() でタイマー開始:
// 定期的に雪を追加(0.2秒ごと)
_snowGeneratorTimer = Timer.periodic(
const Duration(milliseconds: 200),
(timer) {
if (_engine.particleCount < maxParticles) {
_addRandomSnowParticle();
}
},
);
- didChangeDependencies() を変更(初期生成を少なくする):
// 初期は20個だけ生成(後から追加される)
for (int i = 0; i < 20; i++) {
_addRandomSnowParticle();
}
- dispose() でタイマーをキャンセル:
@override
void dispose() {
_controller.dispose();
_snowGeneratorTimer?.cancel(); // 追加
super.dispose();
}
変更点(Step 5との違い):
| Step 5 | Step 6 |
|---|---|
| 50個固定 | 最大100個(動的) |
| 画面下でリセット | 画面下で削除 |
| ループ | 継続的に生成 |
| 初期50個生成 | 初期20個→徐々に増える |
動作確認
flutter run
確認ポイント:
-
パーティクル数:
- 20個からスタート
- 徐々に増えていく
- 最大100個まで増える
- 画面下に消えると減る
-
動き:
- 新しい雪が次々と降ってくる
- 同じ雪がループしない
- 自然な感じ
-
見た目:
- ずっと雪が降り続ける!
- リアルな雪
- 最初に見せた完成版と同じ!
Step 6のまとめ
できたこと:
- 継続的に生成 - Timer.periodic で定期的に追加
- 削除処理 - 画面下で削除
- 最大数制限 - 100個まで
- ずっと雪が降り続ける! - リアルな表現
学んだこと:
- Timer.periodic の使い方
- リストの安全な削除方法
- メモリ管理の基本
- 動的なパーティクル管理
完成!
全6ステップが完了しました!
Step 0: プロジェクト作成
↓
Step 1: 1個の点が落ちる
→ 物理演算の基本
↓
Step 2: 複数の点が降る
→ PhysicsEngine で管理
↓
Step 3: 点を雪の形にする
→ SnowParticle で描画
↓
Step 4: 回転・横揺れを追加
→ リアルな動き
↓
Step 5: スプラッシュ画面として完成
→ ロゴとテキスト
↓
Step 6: 継続的に雪を生成
→ ずっと降り続ける!
学んだこと全体:
- 物理演算の基礎(位置、速度、加速度)
- Flutterのアニメーション(AnimationController)
- Canvasで描画(CustomPaint)
- クラス設計(継承、責務分離)
- 数学(三角関数、sin波)
- メモリ管理(動的生成と削除)
次にできること:
- 他の季節の演出(桜、紅葉、雨)
- パーティクル数を増やす
- 色を変える
- 実際のアプリに組み込む
今回はアドベントカレンダーで冬ということもあり、雪をイメージした何かで記事を作成できないかなと考えた結果このような記事になりました。やってみて高校時代の物理の記憶が蘇り、懐かしい気持ちと難しさがあってなかなか楽しかったです。物理演算はゲームでも使用されているのでリアルさを追求するなら避けて通れない道なのかなと思いました。
ではみなさんお疲れ様でした!
補足説明
このセクションでは、本文で省略した「なぜ?」の部分を詳しく解説します。
補足説明:なぜこの順序で計算するのか?
毎フレームの計算順序:
1. v = v + a × dt
2. p = p + v × dt
3. a = 0 (加速度をリセット)
理由:
- 力は毎フレーム新しく加えるため、加速度をリセットする必要がある
- リセットしないと、加速度がどんどん蓄積されて、異常に速くなってしまう
例:
リセットしない場合:
フレーム1: a = (0, 100)
フレーム2: a = (0, 100) + (0, 100) = (0, 200) ← 2倍!
フレーム3: a = (0, 200) + (0, 100) = (0, 300) ← 3倍!
→ 異常に速くなる
リセットする場合:
フレーム1: a = (0, 100) → リセット → a = (0, 0)
フレーム2: a = (0, 0) + (0, 100) = (0, 100) ← 正常
フレーム3: a = (0, 0) + (0, 100) = (0, 100) ← 正常
→ 一定の加速度
補足説明:なぜ速度もリセットするのか?
リセット処理:
if (p.y > height) {
p.y = 100 // 位置をリセット
v = (0, 0) // 速度をリセット
}
理由:
- 速度をリセットしないと、上に戻った瞬間から速い速度で落ち始めてしまう
- リセットすることで、毎回ゆっくり加速して落ちる
例:
速度をリセットしない場合:
画面下到達時: v = (0, 200) ← 速い!
上に戻る: p = (200, 100), v = (0, 200) ← 速度はそのまま
次のフレーム: すぐに速い速度で落ちる
速度をリセットする場合:
画面下到達時: v = (0, 200)
上に戻る: p = (200, 100), v = (0, 0) ← 速度もリセット
次のフレーム: ゆっくり加速して落ちる
補足説明:なぜdtが必要なのか?
dt(デルタタイム):
dt = 前フレームからの経過時間(秒)
理由:
フレームレートが変わっても、同じ速度で動くようにするため。
dtがない場合:
v = v + a
p = p + v
問題点:
60FPS → 1秒間に60回加算 → 速い
30FPS → 1秒間に30回加算 → 遅い
(フレームレートに依存してしまう)
dtがある場合:
v = v + a × dt
p = p + v × dt
利点:
60FPS → 1回の加算量が小さい(dt ≈ 0.016)
30FPS → 1回の加算量が大きい(dt ≈ 0.033)
(合計は同じになる)
具体例:
1秒間で v = 100 にしたい場合
dtなし(60FPS):
1フレーム: v = v + 100 = 100 ← 1フレームで達成!速すぎる
dtあり(60FPS):
1フレーム: v = v + 100 × 0.016 = 1.6
60フレーム: v = 1.6 × 60 = 96 ≈ 100 ← 1秒かけて達成!正しい
dtあり(30FPS):
1フレーム: v = v + 100 × 0.033 = 3.3
30フレーム: v = 3.3 × 30 = 99 ≈ 100 ← 1秒かけて達成!正しい
補足説明:PhysicsEngineの役割
PhysicsEngineクラスは、全てのパーティクルを管理する「監督」です。
監督(PhysicsEngine)
├─ 選手1(Particle)
├─ 選手2(Particle)
├─ 選手3(Particle)
└─ ...
監督の仕事:
- 全選手に指示を出す(重力を適用)
- 全選手の動きを更新
- 試合(画面)から退場した選手を管理
具体的な処理:
void update(double dt, Size screenSize) {
for (var particle in particles) {
// 1. 全員に重力を適用
particle.applyForce(gravity);
// 2. 全員を更新
particle.update(dt);
// 3. 全員の境界チェック
if (particle.position.y > screenSize.height) {
particle.position.y = -10;
particle.velocity = Vector2D.zero();
}
}
}
メリット:
- コードが1箇所に集約される
- 重力などのパラメータを一元管理
- main.dartがシンプルになる
- 拡張しやすい(新しい処理を追加しやすい)
補足説明:Step 4の値の決め方
風の計算式:sin(t / 2000) × 30
なぜ t / 2000 なのか?
sin(t / 2000) の周期 = 2000ミリ秒 × 2π ≈ 12.6秒
周期を変えると:
t / 1000: 約6秒で1周期 → 速く揺れる
t / 2000: 約13秒で1周期 → ゆっくり揺れる(採用)
t / 5000: 約31秒で1周期 → とてもゆっくり揺れる
決め方:
- 実際に動かして見た目で調整
- 自然な雪の動きに見える周期を選択
- 速すぎると不自然、遅すぎると動きが分からない
なぜ × 30 なのか?
sin(t / 2000) の範囲: -1 〜 1
× 30 すると: -30 〜 30
風の強さを変えると:
× 10: -10 〜 10 → 弱い風、ほとんど揺れない
× 30: -30 〜 30 → 適度な風(採用)
× 100: -100 〜 100 → 強い風、横に飛んでいく
決め方:
- 重力が100なので、それより小さい値にする
- 風が強すぎると雪が横に飛んでしまう
- 風が弱すぎると揺れが分からない
- 重力の30%程度(30 / 100 = 0.3)が自然に見えた
回転速度の範囲:-1.0 〜 1.0
rotationSpeed = -1.0 〜 1.0 ラジアン/秒
1秒間の回転量:
1.0: 約57度回転 (1ラジアン ≈ 57度)
0.5: 約29度回転
-1.0: 約-57度回転(逆回転)
決め方:
- 1.0ラジアン/秒 ≈ 1秒で約57度回転
- 速すぎると目が回る、遅すぎると回転が分からない
- -1.0 〜 1.0 の範囲で時計回りと反時計回りを混在させる
- ランダムな値にすることで、各雪が違う速度で回転
一般的な調整方法:
-
初期値を設定
- まず適当な値を入れてみる
- 例: 風の強さ 50、回転速度 2.0
-
実際に動かして確認
- アプリを実行して動きを見る
- 不自然な部分をメモ
-
値を調整
- 大きすぎる → 小さくする
- 小さすぎる → 大きくする
- 繰り返し調整
-
最適な値を見つける
- 自然に見える値を採用
- この記事では:風30、回転±1.0