Skip to content

Instantly share code, notes, and snippets.

@chooyan-eng
Created July 27, 2026 15:34
Show Gist options
  • Select an option

  • Save chooyan-eng/a102f21ee782b2834b647479712c690c to your computer and use it in GitHub Desktop.

Select an option

Save chooyan-eng/a102f21ee782b2834b647479712c690c to your computer and use it in GitHub Desktop.
a water doodle mat (Aqua Doodle)-style drawing experience
// 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