Created
July 27, 2026 15:34
-
-
Save chooyan-eng/a102f21ee782b2834b647479712c690c to your computer and use it in GitHub Desktop.
a water doodle mat (Aqua Doodle)-style drawing experience
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Water Doodle — single-file version for DartPad. | |
| // | |
| // This concatenates doodle_config.dart / doodle_engine.dart / doodle_painter.dart / | |
| // main.dart from lib/ into one file. It uses no fragment shader; a pure Dart | |
| // implementation that simply stacks two layers of blurred circles via | |
| // MaskFilter.blur. | |
| // | |
| // Usage: open https://dartpad.dev/, select all, paste this content, and Run. | |
| // | |
| // NOTE: lib/ is the source of truth. Do not edit this file directly; sync it | |
| // after changing lib/. | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/scheduler.dart'; | |
| // ============================================================================= | |
| // doodle_config.dart | |
| // ============================================================================= | |
| /// Tunable parameters and pure curve math for the "water doodle" (Aqua Doodle) | |
| /// rendering. | |
| /// | |
| /// This class centralizes the tuning values. To adjust the drawing feel, | |
| /// change the constants here. The curve math (speed -> thickness / elapsed | |
| /// time -> halo & core opacity) is all pure functions with no side effects. | |
| class DoodleConfig { | |
| const DoodleConfig({ | |
| // --- Mat surface --- | |
| this.matColor = const Color(0xFFF6F1E4), | |
| this.wetHaloColor = const Color(0xFF5BB0DE), | |
| this.wetCoreColor = const Color(0xFF1466A6), | |
| this.haloMaxAlpha = 0.42, | |
| this.coreMaxAlpha = 0.90, | |
| // --- Speed -> thickness --- | |
| this.minRadius = 2.5, | |
| this.maxRadius = 15.0, | |
| this.speedReference = 2400.0, | |
| // --- Point generation (interpolation) --- | |
| this.pointSpacing = 3.5, | |
| // --- Bleeding (blur) --- | |
| this.haloBlurSigma = 3.5, | |
| this.coreBlurSigma = 1.6, | |
| this.haloRadiusScale = 1.28, | |
| this.coreRadiusScale = 0.72, | |
| // --- Drying (fade-out) --- | |
| this.haloDryFraction = 0.45, | |
| this.dryRadiusShrink = 0.55, | |
| // --- Performance cap --- | |
| this.maxPoints = 6000, | |
| }); | |
| /// Mat color of the whole surface (white to cream). | |
| final Color matColor; | |
| /// Color that emerges on wet areas. Pale blue on the outer (halo) side. | |
| final Color wetHaloColor; | |
| /// Color that emerges on wet areas. Deep blue on the core side. | |
| final Color wetCoreColor; | |
| /// Maximum opacity of the halo layer. | |
| final double haloMaxAlpha; | |
| /// Maximum opacity of the core layer. | |
| final double coreMaxAlpha; | |
| /// Radius (px) when drawing at maximum speed. | |
| final double minRadius; | |
| /// Radius (px) when drawing while still or slow. Water pools up into a | |
| /// plump, thick blob. | |
| final double maxRadius; | |
| /// At or above this speed (px/sec), the radius sticks to `minRadius`. | |
| final double speedReference; | |
| /// Interval (px) between points placed during a drag. Interpolated so the | |
| /// line does not break up even when moving fast. | |
| final double pointSpacing; | |
| /// Blur amount of the halo layer (standard deviation σ). Approaches | |
| /// [coreBlurSigma] as it dries. | |
| final double haloBlurSigma; | |
| /// Blur amount of the core layer (standard deviation σ). | |
| final double coreBlurSigma; | |
| /// Draw radius of the halo layer = point radius × this. | |
| final double haloRadiusScale; | |
| /// Draw radius of the core layer = point radius × this. | |
| final double coreRadiusScale; | |
| /// The fraction of dry progress (0..1) over which the halo layer fully | |
| /// dries. The core starts drying only after this point. | |
| final double haloDryFraction; | |
| /// Radius multiplier when fully dry. Expresses the puddle shrinking as it | |
| /// dries. | |
| final double dryRadiusShrink; | |
| /// Upper bound on the total number of points (a performance safety valve). | |
| /// When exceeded, the oldest points are trimmed first. | |
| final int maxPoints; | |
| /// Computes the point radius from the movement speed (px/sec). | |
| /// | |
| /// Slow -> thick, fast -> thin. Capped at `speedReference`. | |
| double radiusForSpeed(double speedPxPerSec) { | |
| final t = (speedPxPerSec / speedReference).clamp(0.0, 1.0); | |
| return maxRadius + (minRadius - maxRadius) * t; | |
| } | |
| /// Returns the dry progress p. 0 = the moment drawn, 1 = fully dried. | |
| double dryProgress(double ageMs, double dryDurationMs) { | |
| if (dryDurationMs <= 0) return 1.0; | |
| return (ageMs / dryDurationMs).clamp(0.0, 1.0); | |
| } | |
| /// Opacity factor of the halo layer (0..1). The outer edge dries first, so | |
| /// it reaches 0 early. | |
| double haloOpacity(double p) { | |
| if (haloDryFraction <= 0) return 0.0; | |
| return (1.0 - p / haloDryFraction).clamp(0.0, 1.0); | |
| } | |
| /// Opacity factor of the core layer (0..1). Stays at 1 until | |
| /// [haloDryFraction], then falls toward 0 as p approaches 1. The deep core | |
| /// lingers to the end. | |
| double coreOpacity(double p) { | |
| if (p <= haloDryFraction) return 1.0; | |
| final remain = 1.0 - haloDryFraction; | |
| if (remain <= 0) return 0.0; | |
| return (1.0 - (p - haloDryFraction) / remain).clamp(0.0, 1.0); | |
| } | |
| /// Radius multiplier as it dries (1.0 -> [dryRadiusShrink]). | |
| double radiusScale(double p) => 1.0 + (dryRadiusShrink - 1.0) * p; | |
| /// Bleed σ as it dries (converges from halo toward core). | |
| double haloSigma(double p) => | |
| haloBlurSigma + (coreBlurSigma - haloBlurSigma) * p; | |
| } | |
| // ============================================================================= | |
| // doodle_engine.dart | |
| // ============================================================================= | |
| /// A single drawn water droplet point. | |
| /// | |
| /// Time is held as externally injected milliseconds (`birthMs`) and does not | |
| /// depend on the UI clock. This lets the drying calculation be unit-tested | |
| /// without side effects. | |
| class DoodlePoint { | |
| const DoodlePoint({ | |
| required this.position, | |
| required this.radius, | |
| required this.birthMs, | |
| }); | |
| /// Position on the mat (logical px). | |
| final Offset position; | |
| /// Base radius (px) computed from speed. | |
| final double radius; | |
| /// Birth time (ms). Drying is computed as `now - birthMs`. | |
| final double birthMs; | |
| } | |
| /// Pure function returning the speed between two points in px/sec. | |
| /// | |
| /// Returns 0 when `dtMs` is 0 or less (avoids division by zero). | |
| double speedPxPerSec(Offset from, Offset to, double dtMs) { | |
| if (dtMs <= 0) return 0.0; | |
| return (to - from).distance / dtMs * 1000.0; | |
| } | |
| /// Pure function returning a list of positions interpolated from `from` to | |
| /// `to` at `spacing` intervals. | |
| /// | |
| /// Always includes `to`. Does not include `from` (assumed already placed as | |
| /// the previous point). This keeps the line from breaking up when points jump | |
| /// due to fast movement. | |
| List<Offset> interpolatePoints(Offset from, Offset to, double spacing) { | |
| final distance = (to - from).distance; | |
| if (spacing <= 0 || distance <= spacing) { | |
| return <Offset>[to]; | |
| } | |
| final steps = distance ~/ spacing; | |
| final result = <Offset>[]; | |
| for (var i = 1; i <= steps; i++) { | |
| final t = (i * spacing) / distance; | |
| result.add(Offset.lerp(from, to, t)!); | |
| } | |
| if (result.isEmpty || result.last != to) { | |
| result.add(to); | |
| } | |
| return result; | |
| } | |
| /// Engine that holds the state of the stroke point list. | |
| /// | |
| /// All times are passed in as ms from the caller. Being pure — it holds no | |
| /// internal clock — it can be tested with fake times. It is separated from | |
| /// rendering and gesture handling (UI). | |
| class DoodleEngine { | |
| DoodleEngine({this.config = const DoodleConfig()}); | |
| final DoodleConfig config; | |
| final List<DoodlePoint> points = <DoodlePoint>[]; | |
| Offset? _lastPos; | |
| double? _lastTimeMs; | |
| double _lastRadius = 0.0; | |
| bool get isDrawing => _lastPos != null; | |
| /// Starts a stroke. The speed is unknown at the moment of press, so place a | |
| /// single thick water blob. | |
| void start(Offset pos, double timeMs) { | |
| _lastPos = pos; | |
| _lastTimeMs = timeMs; | |
| _lastRadius = config.maxRadius; | |
| _add(pos, config.maxRadius, timeMs); | |
| } | |
| /// Continues a stroke. Computes speed -> radius and places interpolated | |
| /// points in between. | |
| void extend(Offset pos, double timeMs) { | |
| final last = _lastPos; | |
| final lastTime = _lastTimeMs; | |
| if (last == null || lastTime == null) { | |
| start(pos, timeMs); | |
| return; | |
| } | |
| final speed = speedPxPerSec(last, pos, timeMs - lastTime); | |
| final targetRadius = config.radiusForSpeed(speed); | |
| final positions = interpolatePoints(last, pos, config.pointSpacing); | |
| final count = positions.length; | |
| for (var i = 0; i < count; i++) { | |
| // Smoothly interpolate the radius from the previous value to avoid | |
| // abrupt changes in thickness. | |
| final t = (i + 1) / count; | |
| final radius = _lastRadius + (targetRadius - _lastRadius) * t; | |
| _add(positions[i], radius, timeMs); | |
| } | |
| _lastRadius = targetRadius; | |
| _lastPos = pos; | |
| _lastTimeMs = timeMs; | |
| } | |
| /// Ends a stroke. | |
| void end() { | |
| _lastPos = null; | |
| _lastTimeMs = null; | |
| } | |
| /// Clears all strokes. | |
| void clear() { | |
| points.clear(); | |
| end(); | |
| } | |
| /// Trims fully dried points (elapsed time >= dry duration) from the data. | |
| /// | |
| /// Also discards points exceeding the total cap [DoodleConfig.maxPoints], | |
| /// oldest first. | |
| void cull(double nowMs, double dryDurationMs) { | |
| points.removeWhere((p) => (nowMs - p.birthMs) >= dryDurationMs); | |
| final overflow = points.length - config.maxPoints; | |
| if (overflow > 0) { | |
| points.removeRange(0, overflow); | |
| } | |
| } | |
| void _add(Offset pos, double radius, double timeMs) { | |
| points.add(DoodlePoint(position: pos, radius: radius, birthMs: timeMs)); | |
| } | |
| } | |
| // ============================================================================= | |
| // doodle_painter.dart | |
| // ============================================================================= | |
| /// CustomPainter that draws the water droplet point list onto the mat surface. | |
| /// | |
| /// Each point is drawn in two layers: a "halo layer" (large, strongly blurred) | |
| /// plus a "core layer" (small, weakly blurred). As drying progresses, the halo | |
| /// layer is removed first and the core layer lingers to the end. | |
| class DoodlePainter extends CustomPainter { | |
| DoodlePainter({ | |
| required this.points, | |
| required this.nowMs, | |
| required this.dryDurationMs, | |
| required this.config, | |
| }); | |
| final List<DoodlePoint> points; | |
| final double nowMs; | |
| final double dryDurationMs; | |
| final DoodleConfig config; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| // Mat surface (white to cream). | |
| final matPaint = Paint()..color = config.matColor; | |
| canvas.drawRect(Offset.zero & size, matPaint); | |
| final haloPaint = Paint()..style = PaintingStyle.fill; | |
| final corePaint = Paint() | |
| ..style = PaintingStyle.fill | |
| ..maskFilter = MaskFilter.blur(BlurStyle.normal, config.coreBlurSigma); | |
| // Composite so that overlapping wet colors appear darker (darken). | |
| canvas.saveLayer(Offset.zero & size, Paint()); | |
| for (final p in points) { | |
| final progress = config.dryProgress(nowMs - p.birthMs, dryDurationMs); | |
| final scale = config.radiusScale(progress); | |
| final radius = p.radius * scale; | |
| final halo = config.haloOpacity(progress); | |
| if (halo > 0.0) { | |
| haloPaint | |
| ..color = config.wetHaloColor.withValues( | |
| alpha: halo * config.haloMaxAlpha, | |
| ) | |
| ..maskFilter = MaskFilter.blur( | |
| BlurStyle.normal, | |
| config.haloSigma(progress), | |
| ); | |
| canvas.drawCircle( | |
| p.position, | |
| radius * config.haloRadiusScale, | |
| haloPaint, | |
| ); | |
| } | |
| final core = config.coreOpacity(progress); | |
| if (core > 0.0) { | |
| corePaint.color = config.wetCoreColor.withValues( | |
| alpha: core * config.coreMaxAlpha, | |
| ); | |
| canvas.drawCircle( | |
| p.position, | |
| radius * config.coreRadiusScale, | |
| corePaint, | |
| ); | |
| } | |
| } | |
| canvas.restore(); | |
| } | |
| @override | |
| bool shouldRepaint(covariant DoodlePainter oldDelegate) => true; | |
| } | |
| // ============================================================================= | |
| // main.dart | |
| // ============================================================================= | |
| void main() { | |
| runApp(const WaterDoodleApp()); | |
| } | |
| class WaterDoodleApp extends StatelessWidget { | |
| const WaterDoodleApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| title: 'スイスイおえかき', | |
| debugShowCheckedModeBanner: false, | |
| home: const DoodlePage(), | |
| ); | |
| } | |
| } | |
| class DoodlePage extends StatefulWidget { | |
| const DoodlePage({super.key}); | |
| @override | |
| State<DoodlePage> createState() => _DoodlePageState(); | |
| } | |
| class _DoodlePageState extends State<DoodlePage> | |
| with SingleTickerProviderStateMixin { | |
| static const _config = DoodleConfig(); | |
| /// Dry duration options (seconds). | |
| static const _dryOptions = <int>[5, 15, 60]; | |
| final DoodleEngine _engine = DoodleEngine(config: _config); | |
| /// Clock that handles draw time and dry time in a single domain. | |
| /// Uses a Stopwatch so PointerEvent and Ticker read a common value. | |
| final Stopwatch _clock = Stopwatch(); | |
| late final Ticker _ticker; | |
| int _drySeconds = 15; | |
| double _nowMs = 0.0; | |
| double get _dryMs => _drySeconds * 1000.0; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _clock.start(); | |
| _ticker = createTicker(_onTick)..start(); | |
| } | |
| @override | |
| void dispose() { | |
| _ticker.dispose(); | |
| super.dispose(); | |
| } | |
| void _onTick(Duration _) { | |
| _nowMs = _clock.elapsedMicroseconds / 1000.0; | |
| _engine.cull(_nowMs, _dryMs); | |
| // Repaint every frame (for the drying animation). | |
| setState(() {}); | |
| } | |
| double get _eventMs => _clock.elapsedMicroseconds / 1000.0; | |
| void _onPointerDown(PointerDownEvent e) { | |
| _engine.start(e.localPosition, _eventMs); | |
| } | |
| void _onPointerMove(PointerMoveEvent e) { | |
| _engine.extend(e.localPosition, _eventMs); | |
| } | |
| void _onPointerUp(PointerUpEvent e) { | |
| _engine.end(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| body: Stack( | |
| // If left as loose, the Stack shrinks to the size of the non-Positioned | |
| // child (the controls), and the Positioned.fill drawing surface would | |
| // end up with the same height. | |
| fit: StackFit.expand, | |
| children: <Widget>[ | |
| Positioned.fill( | |
| child: Listener( | |
| behavior: HitTestBehavior.opaque, | |
| onPointerDown: _onPointerDown, | |
| onPointerMove: _onPointerMove, | |
| onPointerUp: _onPointerUp, | |
| onPointerCancel: (_) => _engine.end(), | |
| child: RepaintBoundary( | |
| child: CustomPaint( | |
| painter: DoodlePainter( | |
| points: _engine.points, | |
| nowMs: _nowMs, | |
| dryDurationMs: _dryMs, | |
| config: _config, | |
| ), | |
| size: Size.infinite, | |
| ), | |
| ), | |
| ), | |
| ), | |
| SafeArea( | |
| child: Align( | |
| alignment: Alignment.topLeft, | |
| child: _buildControls(), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| Widget _buildControls() { | |
| return Padding( | |
| padding: const EdgeInsets.all(12.0), | |
| child: Row( | |
| children: <Widget>[ | |
| _Panel( | |
| child: Row( | |
| mainAxisSize: MainAxisSize.min, | |
| children: <Widget>[ | |
| const Padding( | |
| padding: EdgeInsets.only(left: 4.0, right: 8.0), | |
| child: Text('乾燥', style: TextStyle(fontSize: 13)), | |
| ), | |
| for (final s in _dryOptions) _dryChip(s), | |
| ], | |
| ), | |
| ), | |
| const SizedBox(width: 8), | |
| _Panel( | |
| child: TextButton.icon( | |
| onPressed: () => setState(_engine.clear), | |
| icon: const Icon(Icons.cleaning_services_outlined, size: 18), | |
| label: const Text('クリア'), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| Widget _dryChip(int seconds) { | |
| final selected = seconds == _drySeconds; | |
| return Padding( | |
| padding: const EdgeInsets.symmetric(horizontal: 2.0), | |
| child: ChoiceChip( | |
| label: Text('${seconds}s'), | |
| selected: selected, | |
| onSelected: (_) => setState(() => _drySeconds = seconds), | |
| ), | |
| ); | |
| } | |
| } | |
| /// Semi-transparent white panel (backdrop for the controls). | |
| class _Panel extends StatelessWidget { | |
| const _Panel({required this.child}); | |
| final Widget child; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Material( | |
| color: Colors.white.withValues(alpha: 0.82), | |
| borderRadius: BorderRadius.circular(24), | |
| elevation: 1, | |
| child: Padding( | |
| padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0), | |
| child: child, | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment