commit fee35d678b4848f039884b15e988c437838060d5 Author: wuyang Date: Sat Oct 24 22:10:58 2026 +0800 feat: add Paw Play, a cat-friendly Android touch toy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3e96a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build output +app/build/ +app/*.apk +app/*.idsig + +# Local signing material — never commit private keys +app/keystore/ +*.jks +*.keystore +*.p12 + +# Packaged artifacts (APKs live in Releases) +*.apk +*.idsig +猫爪游乐场-源码.zip + +# IDE / OS noise +.idea/ +.vscode/ +*.iml +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9f24f78 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 1.0.1 — 2026-10-24 + +- 修复家长面板关闭后动画停留在 1.0、透明遮罩不消失,导致“继续玩”看起来没反应的问题。 +- 重做羽毛场景:更细的羽轴、可见羽枝、更饱和的配色。 +- 鱼群加入 boid 式分离 + 位置修正,减少叠在一起的情况。 +- 家长入口增加到左上 / 右上两个角落爪印,长按 2 秒。 +- 收紧家长面板高度,补充“继续玩”和“退出游戏”按钮。 + +## 1.0.0 — 2026-10-24 + +- 首个版本:鱼池、红点、羽毛三个模式。 +- 全屏沉浸式,无广告、无联网、无权限。 +- 单 Activity + 自绘 View,60fps。 +- 四段柔和音效,家长面板,返回键防误触。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..540752d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wuyang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8c1fb2 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# 猫爪游乐场 · Paw Play + +给猫猫玩的 Android 触摸玩具。不是让猫盯着看的视频,而是会对爪子有回应的三个小游戏。 + +> 在线仓库: +> 下载 APK: +> 开源协议:MIT + +本项目已在 **小米 M367FC / Android 17(API 37)/ 2272×3408** 上实机验证,没有使用模拟器。 +桌面图标叫 **「猫爪游乐场」**,包名 `com.local.pawplay`,当前版本 **1.0.1**。 + +![鱼池(横屏)](screenshots/05-fish-landscape.png) + +## 三个模式 + +| 鱼池 | 红点 | 羽毛 | +| --- | --- | --- | +| 6–7 条锦鲤在水里游动、悬停、突然加速。爪子碰到鱼会变成气泡和星星,鱼过一会儿从边缘游回来。 | 经典红点。会停、会突然窜出去;爪子拍到它时整颗点会瞬移,并留下红色余晖。 | 三根暖色羽毛在阳光里慢慢飘。挥爪会带起风,羽毛被吹开、旋转,再慢慢落回来。 | +| ![鱼池](screenshots/01-fish-portrait.png) | ![红点](screenshots/03-laser-portrait.png) | ![羽毛](screenshots/04-feather-portrait.png) | + +横屏是更舒服的玩法,竖屏也完整支持。全屏、无状态栏、无导航栏,屏幕常亮。 + +## 为什么这样设计(给猫,而不是给人) + +- **猎物质感**:慢速游动 + 突然加速/停顿 + 方向变化,接近猫会追的小动物。 +- **大目标**:鱼和羽毛都做得够大,猫不需要精确点中。 +- **全屏任意触摸都有反馈**:不是只有“点中按钮”才算。多指、整只爪子拍下来都有水波、气泡或风。 +- **没有失败**:没有分数、倒计时、Game Over、广告、内购、弹窗、联网。 +- **不闪屏**:动画平滑,没有高频闪烁;对猫的视觉更友好。 +- **声音柔和**:四段很短的轻音效,默认开启,可以一键关掉。不会突然爆音。 +- **家长入口藏起来**:要长按角落的爪印 2 秒才会打开,猫乱拍不会误触。 +- **不收集数据**:APK 没有申请任何权限,也没有 INTERNET 权限。 +- **60fps**:单 Activity + 自绘 View + Choreographer,实测 1191 帧里 1 帧 janky(0.08%)。 + +## 下载与安装 + +- Release 里提供已签名的 APK(v1.0.1,约 209 KB) +- 也可以自己从源码构建,见下文 +- 安装后桌面图标为 **「猫爪游乐场」**,首次打开默认进入 **鱼池** + +## 打开与玩法 + +1. 在桌面点 **「猫爪游乐场」** 图标。 +2. 把平板平放或放在稳定的支架上,让猫猫用爪子直接碰屏幕。 +3. 底部提示条会在约 6 秒后自动消失,不挡游戏。 +4. 鱼池、红点、羽毛在 **家长设置** 里切换。 + +### 家长设置 + +**长按左上角或右上角的爪印 2 秒**(按住不要移动),会出现家长面板: + +- 切换 **鱼池 / 红点 / 羽毛**(点一下立即生效,面板不会关,方便继续调) +- **轻柔音效** 开关 +- **继续玩**:关闭面板 +- **退出游戏**:退出并移除任务 + +面板打开时按返回键 = 关闭面板;在游戏界面按一次返回键会提示“再按一次返回键可退出”,2 秒内再按一次才退出。这样猫踩到返回手势不会马上退出。 +如果猫总是从底部手势退出,可以在系统设置里开启 **屏幕固定**(不同系统叫“应用固定/屏幕钉住”),把游戏固定住;退出时再按系统手势解锁。 + +## 猫安全与陪玩建议 + +这不是“把猫交给平板”的产品,建议人陪着玩: + +- **贴钢化膜**:猫爪和屏幕都需要保护,软膜容易被抓花。 +- **放平/支架**:平板不要放在桌边,避免拍下去;远离水杯。 +- **一次 5–10 分钟**:猫出现甩尾、耳朵后压、瞳孔放大、哈气、躲开,就立刻停。 +- **不要强迫**:有的猫对屏幕完全没兴趣,这很正常;换成红点或羽毛试试。 +- **亮度适中**:晚上不要把亮度拉满;不要在黑暗中长时间玩。 +- **注意卫生**:玩完擦一下屏幕;别让猫咬平板边角或充电线。 +- **屏幕不是猫抓板**:如果猫开始用爪子刨屏幕边缘,停下来,换实体逗猫棒。 +- **不是托管工具**:屏幕互动不能替代真实的追逐、扑咬和陪伴。 + +## 安装信息 + +- 应用名:猫爪游乐场 +- 包名:`com.local.pawplay` +- 版本:1.0.1(versionCode 2) +- minSdk 26 / targetSdk 35 +- 大小:约 209 KB +- 权限:**无** +- 签名:本机自签名(不是应用商店版本) +- APK:从 下载 + +安装: + +```bash +adb install -r 猫爪游乐场-1.0.1.apk +``` + +也可以用系统包管理器安装(例如在设备本机的 shell 中): + +```bash +pm install -r --user 0 /data/local/tmp/猫爪游乐场-1.0.1.apk +``` + +启动: + +```bash +am start -n com.local.pawplay/.MainActivity +``` + +## 源码与构建 + +``` +app/ + AndroidManifest.xml + build.sh # 不用 Gradle 的构建脚本 + src/com/local/pawplay/ + MainActivity.java # 全屏、沉浸式、返回键策略 + CatPlayView.java # 单 View:场景调度、触摸、家长面板 + Scene.java # 场景基类 + 粒子 + FishScene.java + LaserScene.java + FeatherScene.java + Particle.java + SoundKit.java + res/ # 图标、字符串、4 个 WAV + keystore/ # 本机自签名密钥(已 gitignore,不提交) +tools/generate_sounds.py # 生成四段轻音效 +screenshots/ # 实机截图 +``` + +### 依赖 + +- JDK 17+ +- Android SDK:Android 35 platform 的 `android.jar` +- Android 34 的 `android.jar`(给系统 `aapt2` 做资源链接;35 的资源表 Ubuntu 版 aapt2 读不了) +- build-tools 35 里的 `lib/d8.jar` +- `aapt2`、`apksigner`、`keytool` +- Python 3(只在重新生成音效时需要) + +### 构建 + +```bash +git clone https://git.k1412.top/wuyang/pawplay.git +cd pawplay +TOOLCHAIN=/path/to/toolchain bash app/build.sh +``` + +如果 `build.sh` 没有执行权限,用 `bash app/build.sh` 即可。 + +可用环境变量覆盖: + +- `ANDROID_JAR`:android-35/android.jar +- `RESOURCE_JAR`:android-34/android.jar +- `D8_JAR`:build-tools/35.0.0/lib/d8.jar +- `AAPT2`:aapt2 可执行文件 +- `KEYSTORE` / `STOREPASS` / `KEYPASS`:签名密钥;不提供时会自动生成 `app/keystore/catplay.jks` + +输出:`app/猫爪游乐场.apk`。 + +重新生成音效: + +```bash +python3 tools/generate_sounds.py +``` + +### 技术要点 + +- 单 Activity + 自定义 `View`,`Choreographer` 驱动,`Canvas` 自绘,无 AndroidX、无 Gradle。 +- 三个场景各自持有绘制状态和粒子池;多指触摸直接映射到场景,不依赖按钮。 +- 家长面板也是 Canvas 绘制,命中区用 `RectF`;长按进度环和 2 秒阈值在 `CatPlayView` 内。 +- 鱼群有目标游动、逃逸、boid 式分离和位置修正,避免叠在一起。 +- 音效由 `tools/generate_sounds.py` 合成 44.1kHz/16-bit 单声道 WAV,`SoundPool` 播放。 +- 全屏采用 `WindowInsetsController`(API 30+)和旧版 immersive flags;刘海安全区从 `DisplayCutout` 读取。 +- 无任何网络或存储权限。 + +## 验证记录 + +- 实机:Xiaomi M367FC,Android 17 / SDK 37,2272×3408,400dpi。 +- 竖屏、横屏都实际运行并截图。 +- `dumpsys gfxinfo`:1191 帧,1 帧 janky(0.08%),50th 5ms,90th 10ms,99th 20ms。 +- 返回键:第一次提示,第二次退出(实机验证)。 +- 家长面板:长按打开、切换模式、**继续玩**关闭(修复了 1.0 里关闭动画卡在 1.0 导致看起来“没反应”的问题)。 +- APK 无权限;安装后桌面图标正常。 + +## 已知限制 + +- 普通应用无法屏蔽系统级“上滑回桌面/侧滑返回”手势;需要更严格的防误触时,用系统的屏幕固定。 +- 家长面板是自绘 UI,没有单独的 Android 无障碍节点;如需读屏支持可以再加。 +- 本 APK 是本机自签名,适合自己安装,不适合直接上架应用商店。 +- 猫对屏幕的兴趣差异很大;不感兴趣就换模式或换实体玩具,不要强迫。 + +## 开源协议 + +代码以 **MIT License** 开源,见 [LICENSE](LICENSE)。 +Copyright (c) 2026 wuyang + +如果这个项目让某只猫多扑了两下,欢迎在 Issues 里分享照片和反馈。 + +## 截图 + +| 横屏鱼池 | 横屏家长面板 | +| --- | --- | +| ![鱼池](screenshots/05-fish-landscape.png) | ![家长面板](screenshots/06-panel-landscape.png) | + +| 横屏红点 | 横屏羽毛 | +| --- | --- | +| ![红点](screenshots/07-laser-landscape.png) | ![羽毛](screenshots/08-feather-landscape.png) | + +| 竖屏家长面板 | 触摸反馈 / 返回提示 | +| --- | --- | +| ![竖屏家长面板](screenshots/02-panel-portrait.png) | ![触摸反馈](screenshots/09-touch-back.png) | diff --git a/app/AndroidManifest.xml b/app/AndroidManifest.xml new file mode 100644 index 0000000..617e52d --- /dev/null +++ b/app/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/app/build.sh b/app/build.sh new file mode 100755 index 0000000..ce1192b --- /dev/null +++ b/app/build.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Build Paw Play without Gradle. Android 35 platform + build-tools 35's d8.jar. +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "$0")" && pwd) +TOOLCHAIN=${TOOLCHAIN:-/root/Documents/Codex/2026-10-12/ub7f-2s8z-chatgpt-1510/work/toolchain} +ANDROID_JAR=${ANDROID_JAR:-$TOOLCHAIN/android-35/android.jar} +RESOURCE_JAR=${RESOURCE_JAR:-$TOOLCHAIN/android-34/android.jar} +D8_JAR=${D8_JAR:-$TOOLCHAIN/android-15/lib/d8.jar} +AAPT2=${AAPT2:-aapt2} +KEYSTORE=${KEYSTORE:-$ROOT/keystore/catplay.jks} +STOREPASS=${STOREPASS:-catplay-local} +KEYPASS=${KEYPASS:-catplay-local} +ALIAS=${ALIAS:-catplay} +BUILD_DIR=${BUILD_DIR:-$ROOT/build} +APK_OUT=${APK_OUT:-$ROOT/猫爪游乐场.apk} + +for f in "$ANDROID_JAR" "$RESOURCE_JAR" "$D8_JAR"; do + [[ -f "$f" ]] || { echo "missing toolchain file: $f" >&2; exit 1; } +done + +if [[ ! -f "$KEYSTORE" ]]; then + mkdir -p "$(dirname "$KEYSTORE")" + keytool -genkeypair -v \ + -keystore "$KEYSTORE" -storetype PKCS12 \ + -alias "$ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass "$STOREPASS" -keypass "$KEYPASS" \ + -dname "CN=PawPlay, OU=Local, O=PawPlay, L=Local, ST=Local, C=CN" >/dev/null +fi + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/gen" "$BUILD_DIR/classes" "$BUILD_DIR/dex" + +"$AAPT2" compile --dir "$ROOT/res" -o "$BUILD_DIR/res.zip" +"$AAPT2" link -o "$BUILD_DIR/unsigned.apk" \ + -I "$RESOURCE_JAR" \ + --manifest "$ROOT/AndroidManifest.xml" \ + --java "$BUILD_DIR/gen" \ + "$BUILD_DIR/res.zip" + +SRC=$(find "$ROOT/src" -name '*.java' -print) +javac -source 8 -target 8 -Xlint:-options -encoding UTF-8 \ + -cp "$ANDROID_JAR" -d "$BUILD_DIR/classes" \ + $SRC "$BUILD_DIR/gen/com/local/pawplay/R.java" + +jar cf "$BUILD_DIR/classes.jar" -C "$BUILD_DIR/classes" . +java -cp "$D8_JAR" com.android.tools.r8.D8 \ + --min-api 26 --lib "$ANDROID_JAR" \ + --output "$BUILD_DIR/dex" "$BUILD_DIR/classes.jar" + +python3 - "$BUILD_DIR" <<'PY' +import sys, pathlib, zipfile +p = pathlib.Path(sys.argv[1]) +with zipfile.ZipFile(p / 'unsigned.apk', 'a', compression=zipfile.ZIP_DEFLATED) as z: + z.write(p / 'dex/classes.dex', 'classes.dex') +PY + +rm -f "$APK_OUT" +apksigner sign \ + --ks "$KEYSTORE" --ks-key-alias "$ALIAS" \ + --ks-pass "pass:$STOREPASS" --key-pass "pass:$KEYPASS" \ + --out "$APK_OUT" "$BUILD_DIR/unsigned.apk" +apksigner verify --verbose "$APK_OUT" | sed -n '1,12p' +echo "Built: $APK_OUT" +ls -lh "$APK_OUT" diff --git a/app/res/drawable/ic_launcher_foreground.xml b/app/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8c27e26 --- /dev/null +++ b/app/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/res/drawable/ic_launcher_legacy.xml b/app/res/drawable/ic_launcher_legacy.xml new file mode 100644 index 0000000..ded7980 --- /dev/null +++ b/app/res/drawable/ic_launcher_legacy.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/res/mipmap-anydpi-v26/ic_launcher.xml b/app/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..59d17b8 --- /dev/null +++ b/app/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/res/mipmap-anydpi/ic_launcher.xml b/app/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..286bb9e --- /dev/null +++ b/app/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,4 @@ + + diff --git a/app/res/raw/chime.wav b/app/res/raw/chime.wav new file mode 100644 index 0000000..56079e3 Binary files /dev/null and b/app/res/raw/chime.wav differ diff --git a/app/res/raw/ping.wav b/app/res/raw/ping.wav new file mode 100644 index 0000000..e49a155 Binary files /dev/null and b/app/res/raw/ping.wav differ diff --git a/app/res/raw/plop.wav b/app/res/raw/plop.wav new file mode 100644 index 0000000..5589b86 Binary files /dev/null and b/app/res/raw/plop.wav differ diff --git a/app/res/raw/whoosh.wav b/app/res/raw/whoosh.wav new file mode 100644 index 0000000..03d772e Binary files /dev/null and b/app/res/raw/whoosh.wav differ diff --git a/app/res/values/colors.xml b/app/res/values/colors.xml new file mode 100644 index 0000000..0f59ffa --- /dev/null +++ b/app/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF6F59 + #FFF7EA + diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml new file mode 100644 index 0000000..fee44ff --- /dev/null +++ b/app/res/values/strings.xml @@ -0,0 +1,19 @@ + + + 猫爪游乐场 + 猫爪游乐场 + 把平板放平,让猫猫开抓 + 家长设置 + 建议贴钢化膜,陪猫猫玩 5–10 分钟就休息。 + 退出游戏 + 关闭 + 轻柔音效 + 鱼池 + 红点 + 羽毛 + 轻触画面 · 猫猫开玩 + 长按角落爪印 2 秒 + 音效已打开 + 音效已关闭 + 再按一次返回键可退出 + diff --git a/app/res/values/styles.xml b/app/res/values/styles.xml new file mode 100644 index 0000000..4eb8be3 --- /dev/null +++ b/app/res/values/styles.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/com/local/pawplay/CatPlayView.java b/app/src/com/local/pawplay/CatPlayView.java new file mode 100644 index 0000000..a6c7102 --- /dev/null +++ b/app/src/com/local/pawplay/CatPlayView.java @@ -0,0 +1,1006 @@ +package com.local.pawplay; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.SystemClock; +import android.util.SparseArray; +import android.view.Choreographer; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; + + +/** + * The whole game is one custom view: three painted scenes, a deliberately + * hidden parent panel, and a very forgiving multi-touch surface for paws. + */ +public final class CatPlayView extends View { + private static final int MODE_FISH = 0; + private static final int MODE_LASER = 1; + private static final int MODE_FEATHER = 2; + private static final long HOLD_MS = 1900L; + + private static final int P_NONE = 0; + private static final int P_OUTSIDE = 1; + private static final int P_CLOSE = 2; + private static final int P_SOUND = 3; + private static final int P_EXIT = 4; + private static final int P_CONTINUE = 5; + private static final int P_MODE_0 = 10; + + private final Scene[] scenes; + private final SoundKit sound; + private final SharedPreferences prefs; + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint shadow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Typeface tfRegular = Typeface.create("sans-serif", Typeface.NORMAL); + private final Typeface tfMedium = Typeface.create("sans-serif-medium", Typeface.NORMAL); + private final Typeface tfBold = Typeface.create("sans-serif", Typeface.BOLD); + + private final Path path = new Path(); + private final Path catEar = new Path(); + private final Path catNose = new Path(); + private final Path speaker = new Path(); + private final Path check = new Path(); + private final RectF card = new RectF(); + private final RectF modeRect[] = {new RectF(), new RectF(), new RectF()}; + private final RectF soundRect = new RectF(); + private final RectF switchRect = new RectF(); + private final RectF closeRect = new RectF(); + private final RectF exitRect = new RectF(); + private final RectF continueRect = new RectF(); + private final RectF hotRect = new RectF(); + private final RectF hotRect2 = new RectF(); + private final RectF holdArc = new RectF(); + private final RectF tmp = new RectF(); + + private final SparseArray pointers = new SparseArray<>(); + + private int w; + private int h; + private float density; + private float scaledDensity; + private float uiScale = 1f; + private int safeL; + private int safeT; + private int safeR; + private int safeB; + + private int mode; + private boolean soundEnabled; + private Scene scene; + + private boolean running; + private long lastNanos; + private float time; + private float panelAnim; + private boolean panelOpen; + private boolean panelNeedsRelease; + private int pressedTarget = P_NONE; + private float downX; + private float downY; + private boolean holding; + private long holdStart; + private int holdPointer = -1; + private float holdX; + private float holdY; + private long lastBackAt; + + private String toast; + private long toastAt; + private int pressedMode = -1; + private boolean pressedSound; + private boolean pressedExit; + private boolean pressedContinue; + private boolean pressedClose; + + private final Choreographer.FrameCallback frameCallback = new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + if (!running) { + return; + } + if (lastNanos == 0L) { + lastNanos = frameTimeNanos; + } + float dt = (frameTimeNanos - lastNanos) / 1_000_000_000f; + lastNanos = frameTimeNanos; + if (dt < 0f) { + dt = 0f; + } + if (dt > 0.05f) { + dt = 0.05f; + } + time += dt; + update(dt); + invalidate(); + Choreographer.getInstance().postFrameCallback(this); + } + }; + + public CatPlayView(Context context) { + super(context); + density = getResources().getDisplayMetrics().density; + scaledDensity = getResources().getDisplayMetrics().scaledDensity; + prefs = context.getSharedPreferences("pawplay", Context.MODE_PRIVATE); + mode = prefs.getInt("mode", MODE_FISH); + if (mode < 0 || mode > 2) { + mode = MODE_FISH; + } + soundEnabled = prefs.getBoolean("sound", true); + sound = new SoundKit(context); + sound.setEnabled(soundEnabled); + scenes = new Scene[]{ + new FishScene(context, sound), + new LaserScene(context, sound), + new FeatherScene(context, sound) + }; + scene = scenes[mode]; + setFocusable(true); + setFocusableInTouchMode(true); + setKeepScreenOn(true); + textPaint.setAntiAlias(true); + stroke.setStyle(Paint.Style.STROKE); + stroke.setStrokeCap(Paint.Cap.ROUND); + } + + public void setSafeInsets(int left, int top, int right, int bottom) { + safeL = Math.max(0, left); + safeT = Math.max(0, top); + safeR = Math.max(0, right); + safeB = Math.max(0, bottom); + computeLayout(); + } + + public void resumeGame() { + if (running) { + return; + } + sound.setEnabled(soundEnabled); + running = true; + lastNanos = 0L; + Choreographer.getInstance().postFrameCallback(frameCallback); + invalidate(); + } + + public void pauseGame() { + running = false; + Choreographer.getInstance().removeFrameCallback(frameCallback); + sound.stopAll(); + pointers.clear(); + holding = false; + holdPointer = -1; + panelNeedsRelease = false; + if (panelOpen) { + panelOpen = false; + panelAnim = 0f; + } + } + + public void releaseGame() { + pauseGame(); + sound.release(); + } + + public boolean handleBack() { + if (panelOpen) { + closePanel(); + return true; + } + long now = SystemClock.uptimeMillis(); + if (now - lastBackAt < 2200L) { + exitGame(); + return true; + } + lastBackAt = now; + showToast(getResources().getString(R.string.exit_hint)); + return true; + } + + private void exitGame() { + if (getContext() instanceof Activity) { + ((Activity) getContext()).finishAndRemoveTask(); + } + } + + @Override + protected void onSizeChanged(int width, int height, int oldw, int oldh) { + super.onSizeChanged(width, height, oldw, oldh); + w = width; + h = height; + for (int i = 0; i < scenes.length; i++) { + scenes[i].resize(w, h, density); + } + computeLayout(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!running) { + resumeGame(); + } + } + + @Override + protected void onDetachedFromWindow() { + pauseGame(); + super.onDetachedFromWindow(); + } + + private void computeLayout() { + if (w <= 0 || h <= 0) { + return; + } + uiScale = Math.min(1f, (h - dp(24f)) / dp(624f)); + uiScale = Math.max(0.70f, uiScale); + + hotRect.set(safeL + u(8f), safeT + u(8f), + safeL + u(8f) + u(100f), safeT + u(8f) + u(100f)); + hotRect2.set(w - safeR - u(108f), safeT + u(8f), + w - safeR - u(8f), safeT + u(8f) + u(100f)); + + float cw = Math.min(w - u(40f), u(720f)); + float ch = Math.min(h - u(40f), u(540f)); + if (cw < u(360f)) { + cw = Math.max(u(300f), w - u(20f)); + } + if (ch < u(440f)) { + ch = Math.max(u(420f), h - u(20f)); + } + float left = (w - cw) * 0.5f; + float top = (h - ch) * 0.5f; + card.set(left, top, left + cw, top + ch); + + float pad = u(32f); + float headerTop = top + u(40f); + float modeTop = headerTop + u(54f) + u(24f); + float modeH = u(176f); + float gap = u(16f); + float mw = (cw - pad * 2f - gap * 2f) / 3f; + for (int i = 0; i < 3; i++) { + float l = left + pad + i * (mw + gap); + modeRect[i].set(l, modeTop, l + mw, modeTop + modeH); + } + + float soundTop = modeTop + modeH + u(20f); + soundRect.set(left + pad, soundTop, left + cw - pad, soundTop + u(62f)); + switchRect.set(soundRect.right - u(78f), soundTop + u(16f), + soundRect.right - u(19f), soundTop + u(46f)); + + closeRect.set(left + cw - pad - u(42f), headerTop - u(3f), + left + cw - pad + u(2f), headerTop + u(41f)); + + float buttonsTop = soundTop + u(62f) + u(57f); + float bw = (cw - pad * 2f - u(14f)) * 0.38f; + exitRect.set(left + pad, buttonsTop, left + pad + bw, buttonsTop + u(58f)); + continueRect.set(exitRect.right + u(14f), buttonsTop, + left + cw - pad, buttonsTop + u(58f)); + } + + private float dp(float value) { + return value * density; + } + + private float u(float value) { + return value * density * uiScale; + } + + private float sp(float value) { + return value * scaledDensity * uiScale; + } + + private void update(float dt) { + float panelTarget = panelOpen ? 1f : 0f; + if (Math.abs(panelAnim - panelTarget) > 0.0001f) { + float step = Math.min(1f, dt * 10f); + panelAnim += (panelTarget - panelAnim) * step; + if (Math.abs(panelAnim - panelTarget) < 0.001f) { + panelAnim = panelTarget; + } + } + + if (holding && holdPointer >= 0) { + long now = SystemClock.uptimeMillis(); + if (now - holdStart >= HOLD_MS) { + holding = false; + openPanel(); + } + } + + if (!panelOpen) { + scene.update(dt, time); + } else { + scene.update(dt * 0.30f, time); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (w <= 0 || h <= 0) { + return; + } + scene.draw(canvas, time); + + if (panelAnim < 0.95f) { + drawOnboarding(canvas); + drawHotspot(canvas); + drawToast(canvas); + } else { + drawHotspot(canvas); + } + + if (panelAnim > 0.001f) { + drawPanel(canvas); + } + } + + private void drawOnboarding(Canvas c) { + if (panelAnim > 0.05f) { + return; + } + fill.setStyle(Paint.Style.FILL); + float a; + if (time < 0.45f) { + a = time / 0.45f; + } else if (time < 4.8f) { + a = 1f; + } else if (time < 6.2f) { + a = 1f - (time - 4.8f) / 1.4f; + } else { + return; + } + a = Math.max(0f, Math.min(1f, a)); + + float cw = Math.min(w - dp(36f), dp(470f)); + float ch = dp(80f); + float left = (w - cw) * 0.5f; + float top = h - safeB - dp(38f) - ch; + tmp.set(left, top, left + cw, top + ch); + + boolean light = scene.isLight(); + int bg = light ? 0xF2FFFBF4 : 0xC50A222A; + fill.setColor(withAlpha(bg, a)); + c.drawRoundRect(tmp, dp(24f), dp(24f), fill); + stroke.setStrokeWidth(dp(1f)); + stroke.setColor(withAlpha(light ? 0x336B4A32 : 0x33FFFFFF, a)); + c.drawRoundRect(tmp, dp(24f), dp(24f), stroke); + + float pawX = left + dp(43f); + float pawY = top + ch * 0.5f; + drawPaw(c, pawX, pawY, dp(21f), + withAlpha(light ? 0xFF2B2430 : 0xFFFFF7EA, a * 0.86f)); + + String title = getResources().getString(R.string.hint_title); + String sub = getResources().getString(R.string.hint_sub) + + " · " + getResources().getString(R.string.hold_hint); + textPaint.setTypeface(tfBold); + textPaint.setTextSize(dp(15f)); + textPaint.setTextAlign(Paint.Align.LEFT); + textPaint.setColor(withAlpha(light ? 0xFF2B2430 : 0xFFFFF7EA, a)); + c.drawText(title, left + dp(76f), top + dp(33f), textPaint); + textPaint.setTypeface(tfRegular); + textPaint.setTextSize(dp(10.5f)); + textPaint.setColor(withAlpha(light ? 0xAA4A3B31 : 0xCCD5E8E5, a)); + c.drawText(sub, left + dp(76f), top + dp(55f), textPaint); + } + + private void drawHotspot(Canvas c) { + boolean light = scene.isLight(); + float alpha = 0.30f; + if (time < 6.4f) { + alpha = 0.36f + (float) Math.sin(time * 4.2f) * 0.14f; + } + drawOneHotspot(c, hotRect, light, alpha); + drawOneHotspot(c, hotRect2, light, alpha); + + if (holding) { + RectF r = hotRect.contains(holdX, holdY) ? hotRect : hotRect2; + float cx = r.centerX(); + float cy = r.centerY(); + long elapsed = SystemClock.uptimeMillis() - holdStart; + float progress = Math.min(1f, elapsed / (float) HOLD_MS); + holdArc.set(cx - dp(37f), cy - dp(37f), cx + dp(37f), cy + dp(37f)); + stroke.setStrokeWidth(dp(3f)); + stroke.setColor(0xFFFF6F59); + c.drawArc(holdArc, -90f, 360f * progress, false, stroke); + } + } + + private void drawOneHotspot(Canvas c, RectF r, boolean light, float alpha) { + float cx = r.centerX(); + float cy = r.centerY(); + if (holding && r.contains(holdX, holdY)) { + alpha = 0.95f; + } + fill.setColor(withAlpha(light ? 0x33FFFFFF : 0x33000000, alpha * 0.95f)); + c.drawCircle(cx, cy, dp(31f), fill); + stroke.setStrokeWidth(dp(1f)); + stroke.setColor(withAlpha(light ? 0x224A3B31 : 0x22FFFFFF, alpha)); + c.drawCircle(cx, cy, dp(31f), stroke); + drawPaw(c, cx, cy + dp(1f), dp(17f), + withAlpha(light ? 0xFF2B2430 : 0xFFFFF7EA, alpha)); + } + + private void drawToast(Canvas c) { + if (toast == null) { + return; + } + long age = SystemClock.uptimeMillis() - toastAt; + if (age > 2300L) { + toast = null; + return; + } + float a = 1f; + if (age < 150L) { + a = age / 150f; + } else if (age > 1950L) { + a = 1f - (age - 1950L) / 350f; + } + a = Math.max(0f, Math.min(1f, a)); + textPaint.setTypeface(tfMedium); + textPaint.setTextSize(dp(13f)); + textPaint.setTextAlign(Paint.Align.LEFT); + float tw = textPaint.measureText(toast); + float pad = dp(18f); + float cw = tw + pad * 2f; + float ch = dp(42f); + float left = (w - cw) * 0.5f; + float top = h - safeB - dp(136f) - ch; + tmp.set(left, top, left + cw, top + ch); + fill.setColor(withAlpha(0xE6152025, a)); + c.drawRoundRect(tmp, ch * 0.5f, ch * 0.5f, fill); + textPaint.setColor(withAlpha(0xFFFFF8ED, a)); + float baseline = top + (ch - (textPaint.ascent() + textPaint.descent())) * 0.5f; + c.drawText(toast, left + pad, baseline, textPaint); + } + + private void showToast(String message) { + toast = message; + toastAt = SystemClock.uptimeMillis(); + } + + private void openPanel() { + if (panelOpen) { + return; + } + panelOpen = true; + panelNeedsRelease = true; + pressedTarget = P_NONE; + panelAnim = Math.max(panelAnim, 0.015f); + sound.playPing(); + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + invalidate(); + } + + private void closePanel() { + panelOpen = false; + pressedTarget = P_NONE; + pressedMode = -1; + pressedSound = false; + pressedExit = false; + pressedContinue = false; + pressedClose = false; + } + + private void setMode(int newMode) { + if (newMode < 0 || newMode > 2 || newMode == mode) { + return; + } + mode = newMode; + scene = scenes[mode]; + prefs.edit().putInt("mode", mode).apply(); + sound.playPing(); + invalidate(); + } + + private void toggleSound() { + soundEnabled = !soundEnabled; + sound.setEnabled(soundEnabled); + prefs.edit().putBoolean("sound", soundEnabled).apply(); + showToast(getResources().getString(soundEnabled + ? R.string.sound_on : R.string.sound_off)); + if (soundEnabled) { + sound.playPing(); + } + } + + private int hitPanel(float x, float y) { + if (!card.contains(x, y)) { + return P_OUTSIDE; + } + if (closeRect.contains(x, y)) { + return P_CLOSE; + } + for (int i = 0; i < 3; i++) { + if (modeRect[i].contains(x, y)) { + return P_MODE_0 + i; + } + } + if (switchRect.contains(x, y) || soundRect.contains(x, y)) { + return P_SOUND; + } + if (exitRect.contains(x, y)) { + return P_EXIT; + } + if (continueRect.contains(x, y)) { + return P_CONTINUE; + } + return P_NONE; + } + + private void updatePressedFlags(int target) { + pressedMode = target >= P_MODE_0 ? target - P_MODE_0 : -1; + pressedSound = target == P_SOUND; + pressedExit = target == P_EXIT; + pressedContinue = target == P_CONTINUE; + pressedClose = target == P_CLOSE; + } + + private boolean handlePanelTouch(MotionEvent e) { + int action = e.getActionMasked(); + if (panelNeedsRelease) { + if (action == MotionEvent.ACTION_DOWN) { + // The release that opened the panel can be lost when the hold + // completes on a later frame. Treat a fresh tap as a new down. + panelNeedsRelease = false; + } else { + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + panelNeedsRelease = false; + } + return true; + } + } + if (panelAnim < 0.72f) { + return true; + } + float x = e.getX(); + float y = e.getY(); + switch (action) { + case MotionEvent.ACTION_DOWN: + downX = x; + downY = y; + pressedTarget = hitPanel(x, y); + updatePressedFlags(pressedTarget); + invalidate(); + return true; + case MotionEvent.ACTION_MOVE: + if (Math.abs(x - downX) > dp(24f) || Math.abs(y - downY) > dp(24f)) { + pressedTarget = P_NONE; + updatePressedFlags(P_NONE); + invalidate(); + } + return true; + case MotionEvent.ACTION_UP: + int target = hitPanel(x, y); + if (target == pressedTarget) { + activatePanel(target); + } + pressedTarget = P_NONE; + updatePressedFlags(P_NONE); + invalidate(); + return true; + case MotionEvent.ACTION_CANCEL: + pressedTarget = P_NONE; + updatePressedFlags(P_NONE); + invalidate(); + return true; + default: + return true; + } + } + + private void activatePanel(int target) { + if (target >= P_MODE_0) { + setMode(target - P_MODE_0); + return; + } + switch (target) { + case P_CLOSE: + case P_OUTSIDE: + case P_CONTINUE: + closePanel(); + break; + case P_SOUND: + toggleSound(); + break; + case P_EXIT: + exitGame(); + break; + default: + break; + } + } + + @Override + public boolean onTouchEvent(MotionEvent e) { + if (panelOpen) { + return handlePanelTouch(e); + } + if (panelAnim > 0.01f) { + return true; + } + + int action = e.getActionMasked(); + int index = e.getActionIndex(); + int id = e.getPointerId(index); + float x = e.getX(index); + float y = e.getY(index); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + pointers.clear(); + pointers.put(id, new PointF(x, y)); + downX = x; + downY = y; + if (hotRect.contains(x, y) || hotRect2.contains(x, y)) { + holding = true; + holdStart = SystemClock.uptimeMillis(); + holdPointer = id; + holdX = x; + holdY = y; + } else { + holding = false; + holdPointer = -1; + scene.touchDown(x, y, time); + } + invalidate(); + return true; + } + case MotionEvent.ACTION_POINTER_DOWN: { + pointers.put(id, new PointF(x, y)); + if (holding) { + holding = false; + holdPointer = -1; + } else { + scene.touchDown(x, y, time); + } + return true; + } + case MotionEvent.ACTION_MOVE: { + for (int i = 0; i < e.getPointerCount(); i++) { + int pid = e.getPointerId(i); + float px = e.getX(i); + float py = e.getY(i); + PointF last = pointers.get(pid); + if (last == null) { + last = new PointF(px, py); + pointers.put(pid, last); + } + if (holding && pid == holdPointer) { + float dx = px - holdX; + float dy = py - holdY; + if (dx * dx + dy * dy > dp(50f) * dp(50f)) { + holding = false; + holdPointer = -1; + } + } else if (!holding) { + float dx = px - last.x; + float dy = py - last.y; + if (dx * dx + dy * dy > dp(7f) * dp(7f)) { + scene.touchMove(px, py, time); + } + } + last.set(px, py); + } + return true; + } + case MotionEvent.ACTION_POINTER_UP: { + pointers.remove(id); + return true; + } + case MotionEvent.ACTION_UP: { + pointers.remove(id); + if (holding) { + long elapsed = SystemClock.uptimeMillis() - holdStart; + if (elapsed < HOLD_MS) { + showToast(getResources().getString(R.string.hold_hint)); + sound.playPing(); + } + holding = false; + holdPointer = -1; + } + return true; + } + case MotionEvent.ACTION_CANCEL: { + pointers.clear(); + holding = false; + holdPointer = -1; + return true; + } + default: + return true; + } + } + + private void drawPanel(Canvas c) { + fill.setStyle(Paint.Style.FILL); + float ease = easeOut(panelAnim); + int dim = (int) (158f * ease); + fill.setColor(Color.argb(dim, 8, 6, 10)); + c.drawRect(0, 0, w, h, fill); + + float dy = (1f - ease) * u(34f); + c.save(); + c.translate(0f, dy); + + float radius = u(30f); + float offset = u(14f); + shadow.setStyle(Paint.Style.FILL); + shadow.setColor(Color.argb((int) (34f * ease), 0, 0, 0)); + tmp.set(card); + tmp.offset(0f, offset); + c.drawRoundRect(tmp, radius, radius, shadow); + shadow.setColor(Color.argb((int) (20f * ease), 0, 0, 0)); + tmp.set(card); + tmp.inset(-u(5f), -u(5f)); + tmp.offset(0f, u(4f)); + c.drawRoundRect(tmp, radius + u(5f), radius + u(5f), shadow); + + fill.setColor(0xFFFFF9EF); + c.drawRoundRect(card, radius, radius, fill); + stroke.setStrokeWidth(u(1f)); + stroke.setColor(0xFFE8D9C4); + c.drawRoundRect(card, radius, radius, stroke); + + drawPanelHeader(c); + drawModeCards(c); + drawSoundRow(c); + drawPanelFooter(c); + c.restore(); + } + + private void drawPanelHeader(Canvas c) { + float pad = u(32f); + float headerTop = card.top + u(40f); + float cx = card.left + pad + u(26f); + float cy = headerTop + u(26f); + + fill.setColor(0xFFFFE4D8); + c.drawCircle(cx, cy, u(26f), fill); + drawCatFace(c, cx, cy, u(16f), 0xFFFF7A5F, 0xFFFFF7EA); + + textPaint.setTypeface(tfBold); + textPaint.setTextSize(sp(22f)); + textPaint.setTextAlign(Paint.Align.LEFT); + textPaint.setColor(0xFF2B2430); + c.drawText(getResources().getString(R.string.parent_hint), + card.left + pad + u(62f), headerTop + u(21f), textPaint); + + textPaint.setTypeface(tfRegular); + textPaint.setTextSize(sp(10.5f)); + textPaint.setColor(0xFF8C7765); + c.drawText("给猫猫的触摸玩具 · 无广告 / 无联网", + card.left + pad + u(62f), headerTop + u(43f), textPaint); + + float ccx = closeRect.centerX(); + float ccy = closeRect.centerY(); + fill.setColor(pressedClose ? 0xFFE7D6C1 : 0xFFF2E7D8); + c.drawCircle(ccx, ccy, u(18f), fill); + textPaint.setTypeface(tfMedium); + textPaint.setTextSize(sp(18f)); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setColor(0xFF6F5C4B); + c.drawText("×", ccx, ccy + u(6f), textPaint); + } + + private void drawModeCards(Canvas c) { + for (int i = 0; i < 3; i++) { + RectF r = modeRect[i]; + boolean selected = mode == i; + boolean pressed = pressedMode == i; + float rr = u(20f); + fill.setColor(pressed ? 0xFFF0E4D4 : (selected ? 0xFFFFF2E7 : 0xFFF5EDE1)); + c.drawRoundRect(r, rr, rr, fill); + stroke.setStrokeWidth(selected ? u(2.4f) : u(1f)); + stroke.setColor(selected ? scenes[i].accent() : 0xFFE8DAC7); + c.drawRoundRect(r, rr, rr, stroke); + + float icx = r.centerX(); + float icy = r.top + u(62f); + fill.setColor(scenes[i].accent()); + c.drawCircle(icx, icy, u(35f), fill); + scenes[i].drawModeIcon(c, icx, icy, u(25f)); + + textPaint.setTypeface(selected ? tfMedium : tfRegular); + textPaint.setTextSize(sp(15.5f)); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setColor(selected ? 0xFF2B2430 : 0xFF6B5949); + c.drawText(getResources().getString(scenes[i].labelRes()), + icx, r.top + u(147f), textPaint); + + if (selected) { + float bx = r.right - u(18f); + float by = r.top + u(18f); + fill.setColor(scenes[i].accent()); + c.drawCircle(bx, by, u(11f), fill); + check.reset(); + check.moveTo(bx - u(5f), by); + check.lineTo(bx - u(1f), by + u(4f)); + check.lineTo(bx + u(5.5f), by - u(4f)); + stroke.setStrokeWidth(u(2.2f)); + stroke.setColor(0xFFFFF9EF); + c.drawPath(check, stroke); + } + } + } + + private void drawSoundRow(Canvas c) { + float rr = u(16f); + fill.setColor(pressedSound ? 0xFFEDE1D1 : 0xFFF5EDDF); + c.drawRoundRect(soundRect, rr, rr, fill); + + float sx = soundRect.left + u(27f); + float sy = soundRect.centerY(); + speaker.reset(); + speaker.moveTo(sx - u(7f), sy - u(4f)); + speaker.lineTo(sx - u(1f), sy - u(4f)); + speaker.lineTo(sx + u(5f), sy - u(10f)); + speaker.lineTo(sx + u(5f), sy + u(10f)); + speaker.lineTo(sx - u(1f), sy + u(4f)); + speaker.lineTo(sx - u(7f), sy + u(4f)); + speaker.close(); + fill.setColor(0xFF7B6652); + c.drawPath(speaker, fill); + stroke.setStrokeWidth(u(1.8f)); + stroke.setColor(0xFF7B6652); + c.drawArc(sx + u(1f), sy - u(8f), sx + u(13f), sy + u(8f), -55f, 110f, false, stroke); + c.drawArc(sx + u(1f), sy - u(13f), sx + u(20f), sy + u(13f), -55f, 110f, false, stroke); + + textPaint.setTypeface(tfRegular); + textPaint.setTextSize(sp(15.5f)); + textPaint.setTextAlign(Paint.Align.LEFT); + textPaint.setColor(0xFF2B2430); + float baseline = sy - (textPaint.ascent() + textPaint.descent()) * 0.5f; + c.drawText(getResources().getString(R.string.sound), + soundRect.left + u(55f), baseline, textPaint); + + float tw = u(52f); + float th = u(30f); + float tl = switchRect.centerX() - tw * 0.5f; + float tt = switchRect.centerY() - th * 0.5f; + tmp.set(tl, tt, tl + tw, tt + th); + fill.setColor(soundEnabled ? 0xFFFF6F59 : 0xFFD8C8B6); + c.drawRoundRect(tmp, th * 0.5f, th * 0.5f, fill); + float knobX = soundEnabled ? tmp.right - th * 0.5f : tmp.left + th * 0.5f; + fill.setColor(0xFFFFFBF3); + c.drawCircle(knobX, tt + th * 0.5f, th * 0.5f - u(3f), fill); + fill.setColor(0x14000000); + c.drawCircle(knobX + u(1f), tt + th * 0.5f + u(2f), th * 0.5f - u(4f), fill); + fill.setColor(0xFFFFFBF3); + c.drawCircle(knobX, tt + th * 0.5f, th * 0.5f - u(3.5f), fill); + } + + private void drawPanelFooter(Canvas c) { + textPaint.setTypeface(tfRegular); + textPaint.setTextSize(sp(10.5f)); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setColor(0xFF8C7765); + c.drawText(getResources().getString(R.string.parent_tip), + card.centerX(), soundRect.bottom + u(30f), textPaint); + + textPaint.setTextSize(sp(9.5f)); + textPaint.setColor(0xFFB3A08E); + c.drawText("无广告 · 无联网 · 不收集数据", + card.centerX(), card.bottom - u(18f), textPaint); + + float rr = u(17f); + fill.setColor(pressedExit ? 0xFFF6D4CA : 0xFFFFE4DC); + c.drawRoundRect(exitRect, rr, rr, fill); + textPaint.setTypeface(tfMedium); + textPaint.setTextSize(sp(15.5f)); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setColor(0xFFC54533); + float ey = exitRect.centerY() - (textPaint.ascent() + textPaint.descent()) * 0.5f; + c.drawText(getResources().getString(R.string.exit), exitRect.centerX(), ey, textPaint); + + fill.setColor(pressedContinue ? 0xFFE85A46 : 0xFFFF6F59); + c.drawRoundRect(continueRect, rr, rr, fill); + textPaint.setColor(0xFFFFFBF3); + float cy = continueRect.centerY() - (textPaint.ascent() + textPaint.descent()) * 0.5f; + c.drawText("继续玩", continueRect.centerX(), cy, textPaint); + } + + private void drawCatFace(Canvas c, float cx, float cy, float r, int face, int line) { + catEar.reset(); + catEar.moveTo(cx - r * 0.92f, cy - r * 0.18f); + catEar.lineTo(cx - r * 0.58f, cy - r * 1.30f); + catEar.lineTo(cx - r * 0.08f, cy - r * 0.64f); + catEar.close(); + catEar.moveTo(cx + r * 0.92f, cy - r * 0.18f); + catEar.lineTo(cx + r * 0.58f, cy - r * 1.30f); + catEar.lineTo(cx + r * 0.08f, cy - r * 0.64f); + catEar.close(); + fill.setColor(face); + c.drawPath(catEar, fill); + c.drawCircle(cx, cy, r, fill); + + // Inner ears. + fill.setColor(0xFFFFC9B9); + catEar.reset(); + catEar.moveTo(cx - r * 0.75f, cy - r * 0.30f); + catEar.lineTo(cx - r * 0.56f, cy - r * 1.00f); + catEar.lineTo(cx - r * 0.28f, cy - r * 0.60f); + catEar.close(); + catEar.moveTo(cx + r * 0.75f, cy - r * 0.30f); + catEar.lineTo(cx + r * 0.56f, cy - r * 1.00f); + catEar.lineTo(cx + r * 0.28f, cy - r * 0.60f); + catEar.close(); + c.drawPath(catEar, fill); + + // Eyes: two calm, slightly closed arcs. + stroke.setStyle(Paint.Style.STROKE); + stroke.setStrokeWidth(Math.max(u(1.4f), r * 0.13f)); + stroke.setColor(line); + c.drawArc(cx - r * 0.62f, cy - r * 0.26f, cx - r * 0.12f, cy + r * 0.10f, + 200f, 140f, false, stroke); + c.drawArc(cx + r * 0.12f, cy - r * 0.26f, cx + r * 0.62f, cy + r * 0.10f, + 200f, 140f, false, stroke); + + catNose.reset(); + catNose.moveTo(cx - r * 0.10f, cy + r * 0.26f); + catNose.lineTo(cx + r * 0.10f, cy + r * 0.26f); + catNose.lineTo(cx, cy + r * 0.40f); + catNose.close(); + fill.setColor(line); + c.drawPath(catNose, fill); + + stroke.setStrokeWidth(Math.max(u(1.1f), r * 0.08f)); + c.drawArc(cx - r * 0.24f, cy + r * 0.30f, cx, cy + r * 0.58f, + 0f, 110f, false, stroke); + c.drawArc(cx, cy + r * 0.30f, cx + r * 0.24f, cy + r * 0.58f, + 70f, 110f, false, stroke); + + if (r > u(12f)) { + stroke.setStrokeWidth(Math.max(u(0.9f), r * 0.05f)); + stroke.setColor((line & 0x00FFFFFF) | 0x99000000); + c.drawLine(cx - r * 1.18f, cy + r * 0.25f, cx - r * 0.55f, cy + r * 0.35f, stroke); + c.drawLine(cx + r * 0.55f, cy + r * 0.35f, cx + r * 1.18f, cy + r * 0.25f, stroke); + } + stroke.setStyle(Paint.Style.STROKE); + } + + private void drawPaw(Canvas c, float cx, float cy, float r, int color) { + fill.setStyle(Paint.Style.FILL); + fill.setColor(color); + c.drawOval(cx - r * 0.56f, cy - r * 0.06f, cx + r * 0.56f, cy + r * 0.78f, fill); + c.drawCircle(cx - r * 0.62f, cy - r * 0.36f, r * 0.23f, fill); + c.drawCircle(cx - r * 0.21f, cy - r * 0.58f, r * 0.23f, fill); + c.drawCircle(cx + r * 0.21f, cy - r * 0.58f, r * 0.23f, fill); + c.drawCircle(cx + r * 0.62f, cy - r * 0.36f, r * 0.23f, fill); + } + + private static float easeOut(float v) { + float x = 1f - Math.max(0f, Math.min(1f, v)); + return 1f - x * x * x; + } + + private static int withAlpha(int color, float alpha) { + int a = (int) (255 * Math.max(0f, Math.min(1f, alpha))); + return (color & 0x00FFFFFF) | (a << 24); + } + + private static final class PointF { + float x; + float y; + + PointF(float x, float y) { + this.x = x; + this.y = y; + } + + void set(float x, float y) { + this.x = x; + this.y = y; + } + } +} diff --git a/app/src/com/local/pawplay/FeatherScene.java b/app/src/com/local/pawplay/FeatherScene.java new file mode 100644 index 0000000..a12240c --- /dev/null +++ b/app/src/com/local/pawplay/FeatherScene.java @@ -0,0 +1,417 @@ +package com.local.pawplay; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RadialGradient; +import android.graphics.Shader; + +import java.util.ArrayList; + +/** + * A sunlit, warm room with slow drifting quill feathers. Touches make a little + * breeze; feathers tumble away and then settle again. + */ +final class FeatherScene extends Scene { + private static final int[] FEATHER_COLORS = { + 0xFF147B76, 0xFFD4543F, 0xFFC08628 + }; + + private final Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final ArrayList feathers = new ArrayList<>(); + private float[] grainX; + private float[] grainY; + private float[] grainR; + private Shader backdrop; + private Shader sun; + private Shader vignette; + private float lastFx = -10f; + private float lastWhoosh = -10f; + private float windX = -999f; + private float windY = -999f; + private float windAge = 10f; + + FeatherScene(Context context, SoundKit sound) { + super(context, sound); + } + + @Override + int labelRes() { + return R.string.mode_feather; + } + + @Override + int accent() { + return 0xFFC08628; + } + + @Override + boolean isLight() { + return true; + } + + @Override + void onResize() { + backdrop = new LinearGradient(0, 0, w * 0.35f, h, + new int[]{0xFFFFFAEE, 0xFFF7E8D2, 0xFFEDD5B8}, + new float[]{0f, 0.58f, 1f}, Shader.TileMode.CLAMP); + sun = new RadialGradient(w * 0.76f, h * 0.10f, Math.max(w, h) * 0.58f, + new int[]{0x66FFF4D2, 0x22FFF4D2, 0x00FFF4D2}, + new float[]{0f, 0.38f, 1f}, Shader.TileMode.CLAMP); + vignette = new RadialGradient(w * 0.5f, h * 0.48f, Math.max(w, h) * 0.76f, + new int[]{0x00000000, 0x00000000, 0x266B4A32}, + new float[]{0f, 0.58f, 1f}, Shader.TileMode.CLAMP); + + feathers.clear(); + int count = 3; + for (int i = 0; i < count; i++) { + Feather f = new Feather(); + f.size = dp(64f + i * 7f + rnd.nextFloat() * 6f); + f.color = FEATHER_COLORS[i % FEATHER_COLORS.length]; + f.x = w * (0.24f + i * 0.26f) + (rnd.nextFloat() - 0.5f) * w * 0.08f; + f.y = h * (0.38f + i * 0.09f) + (rnd.nextFloat() - 0.5f) * h * 0.06f; + f.angle = -32f + i * 22f + (rnd.nextFloat() - 0.5f) * 18f; + f.spin = (rnd.nextFloat() - 0.5f) * 7f; + f.phase = rnd.nextFloat() * 6.283f; + f.vx = (rnd.nextFloat() - 0.5f) * dp(10f); + f.vy = (rnd.nextFloat() - 0.5f) * dp(8f); + f.build(); + feathers.add(f); + } + + int grains = 140; + grainX = new float[grains]; + grainY = new float[grains]; + grainR = new float[grains]; + for (int i = 0; i < grains; i++) { + grainX[i] = rnd.nextFloat() * w; + grainY[i] = rnd.nextFloat() * h; + grainR[i] = dp(0.7f + rnd.nextFloat() * 1.8f); + } + } + + @Override + void update(float dt, float t) { + updateFx(dt); + windAge += dt; + + for (int i = 0; i < feathers.size(); i++) { + Feather f = feathers.get(i); + f.phase += dt * (0.65f + i * 0.06f); + + float targetVx = (float) Math.sin(t * 0.47f + f.phase) * dp(9f); + float targetVy = (float) Math.cos(t * 0.38f + f.phase * 1.3f) * dp(6f) + - dp(2f); + f.vx += (targetVx - f.vx) * Math.min(1f, dt * 0.9f); + f.vy += (targetVy - f.vy) * Math.min(1f, dt * 0.9f); + + if (windAge < 0.65f) { + float dx = f.x - windX; + float dy = f.y - windY; + float dist = (float) Math.sqrt(dx * dx + dy * dy); + if (dist < dp(320f) && dist > 1f) { + float power = (1f - dist / dp(320f)) * dp(950f); + f.vx += dx / dist * power * dt; + f.vy += dy / dist * power * dt - dp(90f) * dt; + f.spin += (rnd.nextFloat() - 0.5f) * 80f * dt; + } + } + + f.x += f.vx * dt; + f.y += f.vy * dt; + f.angle += f.spin * dt; + f.spin *= Math.max(0f, 1f - dt * 0.55f); + f.vx *= Math.max(0f, 1f - dt * 0.20f); + f.vy *= Math.max(0f, 1f - dt * 0.20f); + + float margin = f.size * 1.2f; + if (f.x < margin) { + f.x = margin; + f.vx = Math.abs(f.vx) * 0.7f + dp(7f); + f.spin += 7f; + } else if (f.x > w - margin) { + f.x = w - margin; + f.vx = -Math.abs(f.vx) * 0.7f - dp(7f); + f.spin -= 7f; + } + if (f.y < margin) { + f.y = margin; + f.vy = Math.abs(f.vy) * 0.7f + dp(4f); + } else if (f.y > h - margin) { + f.y = h - margin; + f.vy = -Math.abs(f.vy) * 0.7f - dp(4f); + f.spin += 3f; + } + } + } + + @Override + void touchDown(float x, float y, float t) { + windX = x; + windY = y; + windAge = 0f; + ripple(x, y, 0x552B2430, dp(42f)); + for (int i = 0; i < 11; i++) { + double a = rnd.nextDouble() * 6.28318; + float speed = dp(30f + rnd.nextFloat() * 120f); + Particle p = particle(Particle.DOWN, x, y, + (float) Math.cos(a) * speed, (float) Math.sin(a) * speed, + 0.7f + rnd.nextFloat() * 0.8f, + dp(3f + rnd.nextFloat() * 5f), + i % 3 == 0 ? 0xFFD4543F : 0xFF8A6A4A); + p.gravity = dp(40f); + p.drag = 1.5f; + } + for (int i = 0; i < feathers.size(); i++) { + Feather f = feathers.get(i); + float dx = f.x - x; + float dy = f.y - y; + float dist = (float) Math.sqrt(dx * dx + dy * dy); + if (dist < f.size * 2.7f) { + sparkle(f.x, f.y, f.color, 5, dp(140f)); + if (dist < f.size * 1.25f) { + ripple(f.x, f.y, 0x44FFFFFF, dp(40f)); + } + } + } + if (t - lastWhoosh > 0.18f) { + lastWhoosh = t; + sound.playWhoosh(); + } + } + + @Override + void touchMove(float x, float y, float t) { + windX = x; + windY = y; + windAge = 0f; + if (t - lastFx > 0.035f) { + lastFx = t; + Particle p = particle(Particle.DOWN, x, y, + (rnd.nextFloat() - 0.5f) * dp(80f), + (rnd.nextFloat() - 0.5f) * dp(80f) - dp(10f), + 0.45f + rnd.nextFloat() * 0.4f, + dp(1.5f + rnd.nextFloat() * 2.5f), 0xFF8A6A4A); + p.gravity = dp(50f); + p.drag = 1.8f; + } + } + + @Override + void draw(Canvas c, float t) { + bg.setShader(backdrop); + c.drawRect(0, 0, w, h, bg); + paint.setShader(sun); + c.drawRect(0, 0, w, h, paint); + paint.setShader(null); + + drawGrain(c); + drawAir(c, t); + + for (int i = 0; i < feathers.size(); i++) { + drawFeather(c, feathers.get(i)); + } + + drawFx(c, paint); + + if (windAge < 0.4f) { + float a = (1f - windAge / 0.4f) * 0.18f; + glow.setShader(new RadialGradient(windX, windY, dp(150f), + new int[]{withAlpha(0xFFFFFFFF, a), 0x00FFFFFF}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + c.drawCircle(windX, windY, dp(150f), glow); + glow.setShader(null); + } + + paint.setShader(vignette); + c.drawRect(0, 0, w, h, paint); + paint.setShader(null); + } + + private void drawGrain(Canvas c) { + paint.setStyle(Paint.Style.FILL); + for (int i = 0; i < grainX.length; i++) { + paint.setColor(i % 4 == 0 ? 0x12FFFFFF : 0x0C6B4A32); + c.drawCircle(grainX[i], grainY[i], grainR[i], paint); + } + } + + private void drawAir(Canvas c, float t) { + paint.setStyle(Paint.Style.FILL); + for (int i = 0; i < 18; i++) { + float x = (w * (0.08f + i * 0.051f) + + (float) Math.sin(t * 0.16f + i * 1.7f) * dp(26f)) % w; + float y = (h * (0.12f + i * 0.047f) + + (float) Math.cos(t * 0.12f + i * 1.1f) * dp(30f)) % h; + if (x < 0) { + x += w; + } + if (y < 0) { + y += h; + } + paint.setColor(i % 3 == 0 ? 0x16FFF8E8 : 0x128A6A4A); + c.drawCircle(x, y, dp(1.1f + (i % 3) * 0.5f), paint); + } + } + + private void drawFeather(Canvas c, Feather f) { + float H = f.size * 1.38f; + float W = f.size * 0.40f; + float radians = (float) Math.toRadians(f.angle); + float shX = (float) Math.cos(radians + 2.2f) * dp(13f); + float shY = (float) Math.sin(radians + 2.2f) * dp(13f); + + // Soft cast shadow. + c.save(); + c.translate(f.x + shX, f.y + shY); + c.rotate(f.angle); + paint.setStyle(Paint.Style.FILL); + paint.setShader(null); + paint.setColor(0x1A6B4A32); + c.drawPath(f.vane, paint); + c.restore(); + + c.save(); + c.translate(f.x, f.y); + c.rotate(f.angle); + + paint.setStyle(Paint.Style.FILL); + paint.setAlpha(255); + paint.setShader(f.gradient); + c.drawPath(f.vane, paint); + paint.setShader(null); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(dp(1f)); + paint.setColor(0x242B2430); + c.drawPath(f.vane, paint); + + // Barbs, clipped inside the vane so the edge stays crisp. + c.save(); + c.clipPath(f.vane); + int barbs = 22; + for (int i = 1; i < barbs; i++) { + float u = i / (float) barbs; + float y = -H * 0.88f + H * 1.62f * u; + float width = W * 0.94f * Feather.profile(u); + paint.setStrokeWidth(dp(0.8f)); + paint.setColor(0x55FFF8EA); + c.drawLine(0f, y, -width * 0.94f, y + H * 0.055f, paint); + c.drawLine(0f, y + H * 0.01f, width * 0.94f, y + H * 0.065f, paint); + paint.setColor(0x1A5A3820); + c.drawLine(0f, y, -width * 0.86f, y + H * 0.045f, paint); + c.drawLine(0f, y + H * 0.01f, width * 0.86f, y + H * 0.055f, paint); + } + c.restore(); + + // Rachis and a bare quill. + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setStrokeWidth(dp(3.4f)); + paint.setColor(0xDDFFF8E9); + c.drawLine(0f, -H * 0.95f, 0f, H * 0.90f, paint); + paint.setStrokeWidth(dp(1.1f)); + paint.setColor(0x99322418); + c.drawLine(0f, -H * 0.90f, 0f, H * 0.86f, paint); + + paint.setStrokeWidth(dp(3.2f)); + paint.setColor(0xE6FFF4DF); + c.drawLine(0f, H * 0.80f, 0f, H * 1.13f, paint); + paint.setStrokeWidth(dp(1f)); + paint.setColor(0x664A3522); + c.drawLine(0f, H * 0.84f, 0f, H * 1.08f, paint); + c.restore(); + } + + @Override + void drawModeIcon(Canvas c, float cx, float cy, float size) { + c.save(); + c.translate(cx, cy); + c.rotate(-34f); + float H = size * 0.52f; + paint.setStyle(Paint.Style.FILL); + paint.setColor(0xFFFFF7EA); + Path p = new Path(); + p.moveTo(0f, -H); + p.cubicTo(-size * 0.17f, -H * 0.45f, -size * 0.14f, H * 0.28f, + 0f, H * 0.62f); + p.cubicTo(size * 0.13f, H * 0.26f, size * 0.16f, -H * 0.48f, + 0f, -H); + p.close(); + c.drawPath(p, paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(Math.max(1f, size * 0.045f)); + paint.setColor(0xFFFFF7EA); + c.drawLine(0f, -H * 0.95f, 0f, H * 0.98f, paint); + paint.setStrokeWidth(Math.max(1f, size * 0.03f)); + for (int i = 0; i < 3; i++) { + float yy = -H * 0.28f + i * H * 0.30f; + float xx = size * 0.15f * (1f - i * 0.18f); + c.drawLine(0f, yy, -xx, yy + H * 0.16f, paint); + c.drawLine(0f, yy + H * 0.04f, xx, yy + H * 0.20f, paint); + } + c.restore(); + } + + private static final class Feather { + float x; + float y; + float vx; + float vy; + float angle; + float spin; + float size; + float phase; + int color; + final Path vane = new Path(); + Shader gradient; + + static float profile(float u) { + if (u <= 0f || u >= 1f) { + return 0f; + } + double raw = Math.pow(u, 0.35) * Math.pow(1.0 - u, 0.55); + return (float) (raw / 0.5469); + } + + void build() { + float H = size * 1.38f; + float W = size * 0.40f; + int steps = 30; + vane.reset(); + vane.moveTo(0f, -H * 0.95f); + for (int i = 1; i <= steps; i++) { + float u = i / (float) steps; + float y = -H * 0.88f + H * 1.62f * u; + float x = -W * profile(u) * (0.97f + 0.03f * (float) Math.sin(i * 2.4f)); + vane.lineTo(x, y); + } + vane.lineTo(0f, H * 0.88f); + for (int i = steps; i >= 1; i--) { + float u = i / (float) steps; + float y = -H * 0.88f + H * 1.62f * u; + float x = W * profile(u) * (0.97f + 0.03f * (float) Math.sin(i * 2.1f + 1.3f)); + vane.lineTo(x, y); + } + vane.close(); + + int tip = mix(color, 0xFFFFF6E4, 0.46f); + int base = mix(color, 0xFF2B1A12, 0.10f); + gradient = new LinearGradient(0f, -H * 0.95f, 0f, H * 0.9f, + new int[]{tip, color, base}, + new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP); + } + + static int mix(int a, int b, float t) { + int r = (int) (Color.red(a) * (1 - t) + Color.red(b) * t); + int g = (int) (Color.green(a) * (1 - t) + Color.green(b) * t); + int bl = (int) (Color.blue(a) * (1 - t) + Color.blue(b) * t); + return 0xFF000000 | (r << 16) | (g << 8) | bl; + } + } +} diff --git a/app/src/com/local/pawplay/FishScene.java b/app/src/com/local/pawplay/FishScene.java new file mode 100644 index 0000000..9674693 --- /dev/null +++ b/app/src/com/local/pawplay/FishScene.java @@ -0,0 +1,611 @@ +package com.local.pawplay; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RadialGradient; +import android.graphics.Shader; + +import java.util.ArrayList; + +/** + * A calm koi pond. Fish wander, dart, and flee from a paw. Direct hits turn + * into a puff of bubbles and the fish quietly returns a moment later. + */ +final class FishScene extends Scene { + private static final int[] FISH_COLORS = { + 0xFFFF7A59, 0xFFFFC857, 0xFFFFF1DC, + 0xFF66E0D4, 0xFFFF9FB2, 0xFF7FB7FF, + 0xFFFFA45B + }; + + private final Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final ArrayList fishes = new ArrayList<>(); + private final ArrayList bubbles = new ArrayList<>(); + private float[] speckX; + private float[] speckY; + private float[] speckR; + private final Path wave = new Path(); + private final Path rays = new Path(); + private Shader water; + private Shader vignette; + private float lastFxTime = -10f; + private float lastPlop = -10f; + private float touchGlowX = -999f; + private float touchGlowY = -999f; + private float touchGlowAge = 10f; + + FishScene(Context context, SoundKit sound) { + super(context, sound); + } + + @Override + int labelRes() { + return R.string.mode_fish; + } + + @Override + int accent() { + return 0xFF0E7C86; + } + + @Override + boolean isLight() { + return false; + } + + @Override + void onResize() { + water = new LinearGradient(0, 0, w * 0.28f, h, + new int[]{0xFF0C4653, 0xFF0A3742, 0xFF061E27}, + new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP); + vignette = new RadialGradient(w * 0.5f, h * 0.42f, Math.max(w, h) * 0.72f, + new int[]{0x00000000, 0x00000000, 0x7A000000}, + new float[]{0f, 0.52f, 1f}, Shader.TileMode.CLAMP); + + fishes.clear(); + int count = 6; + if (w > h) { + count = 7; + } + for (int i = 0; i < count; i++) { + Fish f = new Fish(); + f.size = dp(50f + rnd.nextFloat() * 20f); + f.color = FISH_COLORS[i % FISH_COLORS.length]; + f.pattern = rnd.nextBoolean() ? 1 : 0; + placeFish(f, true); + for (int attempt = 0; attempt < 70; attempt++) { + boolean clear = true; + for (int j = 0; j < fishes.size(); j++) { + Fish other = fishes.get(j); + float dx = f.x - other.x; + float dy = f.y - other.y; + float min = (f.size + other.size) * 1.75f; + if (dx * dx + dy * dy < min * min) { + clear = false; + break; + } + } + if (clear) { + break; + } + placeFish(f, true); + } + fishes.add(f); + } + + speckX = new float[90]; + speckY = new float[90]; + speckR = new float[90]; + for (int i = 0; i < speckX.length; i++) { + speckX[i] = rnd.nextFloat() * w; + speckY[i] = rnd.nextFloat() * h; + speckR[i] = dp(0.6f + rnd.nextFloat() * 1.9f); + } + + bubbles.clear(); + int bubbleCount = Math.max(18, (int) (w / dp(80f))); + for (int i = 0; i < bubbleCount; i++) { + Bubble b = new Bubble(); + b.x = rnd.nextFloat() * w; + b.y = rnd.nextFloat() * h; + b.r = dp(1.2f + rnd.nextFloat() * 3.8f); + b.speed = dp(8f + rnd.nextFloat() * 26f); + b.sway = dp(4f + rnd.nextFloat() * 16f); + b.phase = rnd.nextFloat() * 6.283f; + b.alpha = 0.10f + rnd.nextFloat() * 0.22f; + bubbles.add(b); + } + } + + private void placeFish(Fish f, boolean initial) { + float m = f.size * 2.2f + dp(18f); + if (initial) { + f.x = m + rnd.nextFloat() * Math.max(1f, w - m * 2f); + f.y = m + rnd.nextFloat() * Math.max(1f, h - m * 2f); + } else { + int side = rnd.nextInt(4); + if (side == 0) { + f.x = m; + f.y = m + rnd.nextFloat() * Math.max(1f, h - m * 2f); + } else if (side == 1) { + f.x = w - m; + f.y = m + rnd.nextFloat() * Math.max(1f, h - m * 2f); + } else if (side == 2) { + f.x = m + rnd.nextFloat() * Math.max(1f, w - m * 2f); + f.y = m; + } else { + f.x = m + rnd.nextFloat() * Math.max(1f, w - m * 2f); + f.y = h - m; + } + } + f.angle = rnd.nextFloat() * 6.283f; + f.baseSpeed = dp(55f + rnd.nextFloat() * 48f); + f.vx = (float) Math.cos(f.angle) * f.baseSpeed; + f.vy = (float) Math.sin(f.angle) * f.baseSpeed; + f.phase = rnd.nextFloat() * 6.283f; + f.retarget = 0f; + f.boost = 0f; + f.hidden = false; + f.respawn = 0f; + f.blink = 2f + rnd.nextFloat() * 5f; + f.targetX = f.x; + f.targetY = f.y; + } + + @Override + void update(float dt, float t) { + updateFx(dt); + touchGlowAge += dt; + + for (int i = 0; i < fishes.size(); i++) { + Fish f = fishes.get(i); + if (f.hidden) { + f.respawn -= dt; + if (f.respawn <= 0f) { + f.x = -dp(100f); + f.y = dp(100f) + rnd.nextFloat() * Math.max(1f, h - dp(200f)); + f.angle = 0f; + f.vx = dp(120f); + f.vy = 0f; + f.hidden = false; + f.blink = 1f + rnd.nextFloat() * 2f; + } + continue; + } + + f.blink -= dt; + if (f.blink < -0.16f) { + f.blink = 2.5f + rnd.nextFloat() * 5.5f; + } + + f.retarget -= dt; + float dx = f.targetX - f.x; + float dy = f.targetY - f.y; + float dist = (float) Math.sqrt(dx * dx + dy * dy); + if (f.retarget <= 0f || dist < dp(55f)) { + float marginX = f.size * 2.0f + dp(24f); + float marginY = f.size * 2.0f + dp(24f); + f.targetX = marginX + rnd.nextFloat() * Math.max(1f, w - marginX * 2f); + f.targetY = marginY + rnd.nextFloat() * Math.max(1f, h - marginY * 2f); + f.retarget = 2.2f + rnd.nextFloat() * 3.8f; + } + + if (rnd.nextFloat() < dt * 0.13f) { + f.boost = Math.max(f.boost, 0.8f + rnd.nextFloat() * 0.7f); + } + f.boost = Math.max(0f, f.boost - dt * 0.8f); + + float desired = (float) Math.atan2(f.targetY - f.y, f.targetX - f.x); + float speed = f.baseSpeed * (1f + f.boost * 1.7f); + float tx = (float) Math.cos(desired) * speed; + float ty = (float) Math.sin(desired) * speed; + + float follow = f.boost > 0.1f ? 2.1f : 0.9f; + f.vx += (tx - f.vx) * Math.min(1f, dt * follow); + f.vy += (ty - f.vy) * Math.min(1f, dt * follow); + + // Flee from the most recent paw touch. + if (touchGlowAge < 0.55f) { + float sx = f.x - touchGlowX; + float sy = f.y - touchGlowY; + float sd = (float) Math.sqrt(sx * sx + sy * sy); + if (sd < dp(190f) && sd > 1f) { + float push = dp(360f) * (1f - sd / dp(190f)); + f.vx += sx / sd * push * dt * 9f; + f.vy += sy / sd * push * dt * 9f; + f.targetX = f.x + sx * 2f; + f.targetY = f.y + sy * 2f; + f.retarget = 1.2f; + } + } + + // Gentle boid-like separation keeps the pond from clumping. + for (int j = 0; j < fishes.size(); j++) { + if (j == i) { + continue; + } + Fish other = fishes.get(j); + if (other.hidden) { + continue; + } + float sx = f.x - other.x; + float sy = f.y - other.y; + float min = (f.size + other.size) * 1.45f; + float d2 = sx * sx + sy * sy; + if (d2 > 1f && d2 < min * min) { + float sdist = (float) Math.sqrt(d2); + float overlap = min - sdist; + // A little positional correction keeps big koi readable + // even when several of them crowd the same corner. + f.x += sx / sdist * overlap * 0.28f; + f.y += sy / sdist * overlap * 0.28f; + float push = (1f - sdist / min) * dp(420f); + f.vx += sx / sdist * push * dt; + f.vy += sy / sdist * push * dt; + } + } + + float maxSpeed = f.baseSpeed * (1f + f.boost * 2.4f); + float sp = (float) Math.sqrt(f.vx * f.vx + f.vy * f.vy); + if (sp > maxSpeed && sp > 1f) { + f.vx = f.vx / sp * maxSpeed; + f.vy = f.vy / sp * maxSpeed; + } + + f.x += f.vx * dt; + f.y += f.vy * dt; + f.angle = (float) Math.atan2(f.vy, f.vx); + f.phase += dt * (5f + sp / dp(42f)); + + float m = f.size * 2.2f + dp(10f); + if (f.x < m) { + f.x = m; + f.vx = Math.abs(f.vx); + f.targetX = f.size * 2.2f + dp(40f); + } else if (f.x > w - m) { + f.x = w - m; + f.vx = -Math.abs(f.vx); + f.targetX = w - f.size * 2.2f - dp(40f); + } + if (f.y < m) { + f.y = m; + f.vy = Math.abs(f.vy); + f.targetY = f.size * 2.2f + dp(40f); + } else if (f.y > h - m) { + f.y = h - m; + f.vy = -Math.abs(f.vy); + f.targetY = h - f.size * 2.2f - dp(40f); + } + } + + for (int i = 0; i < bubbles.size(); i++) { + Bubble b = bubbles.get(i); + b.phase += dt * 0.7f; + b.y -= b.speed * dt; + b.x += (float) Math.sin(b.phase) * b.sway * dt; + if (b.y < -dp(20f)) { + b.y = h + dp(20f); + b.x = rnd.nextFloat() * w; + } + } + } + + @Override + void touchDown(float x, float y, float t) { + touchGlowX = x; + touchGlowY = y; + touchGlowAge = 0f; + ripple(x, y, 0xCCBFF7F1, dp(52f)); + ripple(x, y, 0x66FFFFFF, dp(30f)); + for (int i = 0; i < 5; i++) { + float a = rnd.nextFloat() * 6.283f; + float sp = dp(20f + rnd.nextFloat() * 60f); + Particle p = particle(Particle.BUBBLE, x, y, + (float) Math.cos(a) * sp, (float) Math.sin(a) * sp, + 0.5f + rnd.nextFloat() * 0.5f, + dp(2f + rnd.nextFloat() * 4f), 0xCCDFFFF9); + p.gravity = -dp(40f); + } + if (t - lastPlop > 0.10f) { + lastPlop = t; + sound.playPlop(); + } + catchNear(x, y); + } + + @Override + void touchMove(float x, float y, float t) { + touchGlowX = x; + touchGlowY = y; + touchGlowAge = 0f; + if (t - lastFxTime > 0.075f) { + lastFxTime = t; + ripple(x, y, 0x55BFF7F1, dp(30f)); + for (int i = 0; i < 2; i++) { + float a = rnd.nextFloat() * 6.283f; + float sp = dp(10f + rnd.nextFloat() * 25f); + particle(Particle.BUBBLE, x, y, + (float) Math.cos(a) * sp, (float) Math.sin(a) * sp, + 0.35f + rnd.nextFloat() * 0.4f, + dp(1.5f + rnd.nextFloat() * 2.5f), 0x88DFFFF9); + } + } + } + + private void catchNear(float x, float y) { + for (int i = 0; i < fishes.size(); i++) { + Fish f = fishes.get(i); + if (f.hidden) { + continue; + } + float dx = f.x - x; + float dy = f.y - y; + float r = f.size * 1.9f; + if (dx * dx + dy * dy < r * r) { + f.hidden = true; + f.respawn = 1.1f + rnd.nextFloat() * 0.7f; + sparkle(f.x, f.y, f.color, 16, dp(260f)); + sparkle(f.x, f.y, 0xFFFFF0C7, 8, dp(170f)); + ripple(f.x, f.y, 0x99FFFFFF, dp(46f)); + for (int b = 0; b < 9; b++) { + float a = rnd.nextFloat() * 6.283f; + float sp = dp(30f + rnd.nextFloat() * 80f); + Particle p = particle(Particle.BUBBLE, f.x, f.y, + (float) Math.cos(a) * sp, (float) Math.sin(a) * sp, + 0.6f + rnd.nextFloat() * 0.7f, + dp(2f + rnd.nextFloat() * 4f), 0xCCFFFFFF); + p.gravity = -dp(55f); + } + sound.playChime(); + return; + } + } + } + + @Override + void draw(Canvas c, float t) { + bg.setDither(true); + bg.setShader(water); + c.drawRect(0, 0, w, h, bg); + + drawCaustics(c, t); + drawBubbles(c); + drawSpecks(c); + + for (int i = 0; i < fishes.size(); i++) { + Fish f = fishes.get(i); + if (!f.hidden) { + drawFish(c, f, t); + } + } + + drawFx(c, paint); + + // Soft light following the most recent touch. + if (touchGlowAge < 0.5f) { + float a = (1f - touchGlowAge / 0.5f) * 0.13f; + glow.setShader(new RadialGradient(touchGlowX, touchGlowY, dp(130f), + new int[]{withAlpha(0xFFFFFFFF, a), 0x00FFFFFF}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + c.drawCircle(touchGlowX, touchGlowY, dp(130f), glow); + glow.setShader(null); + } + + paint.setShader(vignette); + c.drawRect(0, 0, w, h, paint); + paint.setShader(null); + } + + private void drawCaustics(Canvas c, float t) { + stroke.setStyle(Paint.Style.STROKE); + stroke.setStrokeCap(Paint.Cap.ROUND); + stroke.setStrokeWidth(dp(15f)); + int lines = 4; + for (int i = 0; i < lines; i++) { + float baseY = h * (0.13f + i * 0.20f); + wave.reset(); + wave.moveTo(-dp(20f), baseY); + int steps = 16; + for (int s = 1; s <= steps; s++) { + float x = w * s / (float) steps; + float y = baseY + + (float) Math.sin(x * 0.0048f + t * 0.55f + i * 1.7f) * dp(16f) + + (float) Math.sin(x * 0.012f - t * 0.38f + i) * dp(5f); + wave.lineTo(x, y); + } + stroke.setColor((0x0D + i * 0x02) << 24 | 0x00E9FFFB); + c.drawPath(wave, stroke); + } + } + + private void drawSpecks(Canvas c) { + paint.setStyle(Paint.Style.FILL); + for (int i = 0; i < speckX.length; i++) { + paint.setColor(i % 5 == 0 ? 0x18DFFFF9 : 0x0A9FE6E4); + c.drawCircle(speckX[i], speckY[i], speckR[i], paint); + } + } + + private void drawBubbles(Canvas c) { + paint.setStyle(Paint.Style.FILL); + for (int i = 0; i < bubbles.size(); i++) { + Bubble b = bubbles.get(i); + paint.setColor((int) (b.alpha * 255) << 24 | 0x00DFFFF9); + c.drawCircle(b.x, b.y, b.r, paint); + paint.setColor((int) (b.alpha * 0.55f * 255) << 24 | 0x00FFFFFF); + c.drawCircle(b.x - b.r * 0.3f, b.y - b.r * 0.3f, b.r * 0.35f, paint); + } + } + + private void drawFish(Canvas c, Fish f, float t) { + float L = f.size * 2.05f; + float H = f.size * 0.95f; + float wag = (float) Math.sin(f.phase) * 0.42f; + + c.save(); + c.translate(f.x, f.y); + c.rotate((float) Math.toDegrees(f.angle)); + if (Math.cos(f.angle) < 0f) { + c.scale(1f, -1f); + } + + // Tail, behind the body. + f.tail.reset(); + f.tail.moveTo(-L * 0.70f, 0f); + f.tail.cubicTo(-L * 0.90f, -H * 0.55f + wag * H, + -L * 1.30f, -H * 0.80f + wag * H, + -L * 1.12f, 0f); + f.tail.cubicTo(-L * 1.30f, H * 0.80f + wag * H, + -L * 0.90f, H * 0.55f + wag * H, + -L * 0.70f, 0f); + f.tail.close(); + paint.setStyle(Paint.Style.FILL); + paint.setColor(shade(f.color, 0.72f)); + c.drawPath(f.tail, paint); + + // Dorsal fin, just behind the body. + f.dorsal.reset(); + f.dorsal.moveTo(-L * 0.04f, -H * 0.72f); + f.dorsal.quadTo(L * 0.15f, -H * 1.28f, L * 0.40f, -H * 0.50f); + f.dorsal.close(); + paint.setColor(shade(f.color, 0.88f)); + c.drawPath(f.dorsal, paint); + + // Pectoral fin. + f.fin.reset(); + f.fin.moveTo(L * 0.18f, H * 0.10f); + f.fin.quadTo(L * 0.02f, H * 0.78f, -L * 0.18f, H * 0.32f); + f.fin.close(); + paint.setColor(shade(f.color, 0.86f)); + c.drawPath(f.fin, paint); + + // Body. + f.body.reset(); + f.body.moveTo(L * 0.94f, 0f); + f.body.cubicTo(L * 0.60f, -H * 0.96f, -L * 0.40f, -H, + -L * 0.80f, 0f); + f.body.cubicTo(-L * 0.40f, H, L * 0.60f, H * 0.96f, L * 0.94f, 0f); + f.body.close(); + paint.setColor(f.color); + c.drawPath(f.body, paint); + + // Koi patches and a pale belly. + c.save(); + c.clipPath(f.body); + if (f.pattern == 1) { + paint.setColor(0xCCFFF6E7); + c.drawCircle(-L * 0.18f, -H * 0.24f, H * 0.55f, paint); + c.drawCircle(L * 0.34f, H * 0.30f, H * 0.42f, paint); + paint.setColor(0x55FFFFFF); + c.drawCircle(-L * 0.55f, H * 0.14f, H * 0.38f, paint); + } + paint.setColor(0x33FFF8EA); + c.drawOval(-L * 0.62f, H * 0.15f, L * 0.65f, H * 1.15f, paint); + c.restore(); + + // Outline keeps every colour readable on the dark water. + stroke.setStyle(Paint.Style.STROKE); + stroke.setStrokeWidth(dp(2.2f)); + stroke.setColor(0x55201A19); + c.drawPath(f.body, stroke); + + // Eye. + paint.setStyle(Paint.Style.FILL); + paint.setColor(0xFFFDFBF5); + c.drawCircle(L * 0.60f, -H * 0.12f, H * 0.15f, paint); + if (f.blink < 0f) { + stroke.setStyle(Paint.Style.STROKE); + stroke.setStrokeWidth(dp(2f)); + stroke.setColor(0xFF292326); + c.drawLine(L * 0.53f, -H * 0.12f, L * 0.67f, -H * 0.12f, stroke); + } else { + paint.setColor(0xFF292326); + c.drawCircle(L * 0.615f, -H * 0.12f, H * 0.072f, paint); + paint.setColor(0xFFFFFFFF); + c.drawCircle(L * 0.635f, -H * 0.15f, H * 0.026f, paint); + } + + // Tiny highlight on the back. + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(dp(2f)); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setColor(0x55FFFFFF); + c.drawLine(L * 0.08f, -H * 0.52f, L * 0.42f, -H * 0.44f, paint); + c.restore(); + } + + @Override + void drawModeIcon(Canvas c, float cx, float cy, float size) { + c.save(); + c.translate(cx, cy); + c.rotate(-8f); + float L = size * 1.1f; + float H = size * 0.36f; + paint.setStyle(Paint.Style.FILL); + paint.setColor(0xFFFFF7EA); + Path p = new Path(); + p.moveTo(L * 0.48f, 0f); + p.cubicTo(L * 0.18f, -H, -L * 0.55f, -H, -L * 0.62f, 0f); + p.cubicTo(-L * 0.55f, H, L * 0.18f, H, L * 0.48f, 0f); + p.close(); + Path tail = new Path(); + tail.moveTo(-L * 0.55f, 0f); + tail.lineTo(-L * 0.95f, -H * 0.9f); + tail.lineTo(-L * 0.88f, 0f); + tail.lineTo(-L * 0.95f, H * 0.9f); + tail.close(); + c.drawPath(tail, paint); + c.drawPath(p, paint); + paint.setColor(0xFF0E7C86); + c.drawCircle(L * 0.30f, -H * 0.10f, Math.max(1f, size * 0.075f), paint); + c.restore(); + } + + private static int shade(int color, float factor) { + int r = (int) (Color.red(color) * factor); + int g = (int) (Color.green(color) * factor); + int b = (int) (Color.blue(color) * factor); + return 0xFF000000 | (r << 16) | (g << 8) | b; + } + + private static final class Fish { + float x; + float y; + float vx; + float vy; + float angle; + float size; + float phase; + float baseSpeed; + float targetX; + float targetY; + float retarget; + float boost; + float blink; + float respawn; + boolean hidden; + int color; + int pattern; + final Path body = new Path(); + final Path tail = new Path(); + final Path fin = new Path(); + final Path dorsal = new Path(); + } + + private static final class Bubble { + float x; + float y; + float r; + float speed; + float sway; + float phase; + float alpha; + } +} diff --git a/app/src/com/local/pawplay/LaserScene.java b/app/src/com/local/pawplay/LaserScene.java new file mode 100644 index 0000000..7251c98 --- /dev/null +++ b/app/src/com/local/pawplay/LaserScene.java @@ -0,0 +1,370 @@ +package com.local.pawplay; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RadialGradient; +import android.graphics.Shader; + +import java.util.ArrayList; + +/** + * The classic red dot: organic pauses, sudden darts, and an instant jump when + * a paw lands on it. No score, no failure, just pursuit. + */ +final class LaserScene extends Scene { + private final Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final ArrayList trail = new ArrayList<>(); + private final ArrayList motes = new ArrayList<>(); + + private Shader backdrop; + private Shader vignette; + private Shader dotGlow; + private Shader tightGlow; + private float x; + private float y; + private float vx; + private float vy; + private float tx; + private float ty; + private float pause; + private float pulse; + private float tempo = 1f; + private float retarget; + private float lastPing = -10f; + + LaserScene(Context context, SoundKit sound) { + super(context, sound); + } + + @Override + int labelRes() { + return R.string.mode_laser; + } + + @Override + int accent() { + return 0xFFFF5A4E; + } + + @Override + boolean isLight() { + return false; + } + + @Override + void onResize() { + backdrop = new RadialGradient(w * 0.54f, h * 0.42f, Math.max(w, h) * 0.82f, + new int[]{0xFF2A2033, 0xFF17111F, 0xFF08060D}, + new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP); + vignette = new RadialGradient(w * 0.5f, h * 0.5f, Math.max(w, h) * 0.78f, + new int[]{0x00000000, 0x00000000, 0x99000000}, + new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP); + dotGlow = new RadialGradient(0, 0, dp(150f), + new int[]{0xAAFF3A2E, 0x55FF2A20, 0x00FF1A10}, + new float[]{0f, 0.28f, 1f}, Shader.TileMode.CLAMP); + tightGlow = new RadialGradient(0, 0, dp(44f), + new int[]{0xFFFFB9AE, 0x99FF4A3C, 0x00FF2A20}, + new float[]{0f, 0.45f, 1f}, Shader.TileMode.CLAMP); + + x = w * 0.5f; + y = h * 0.5f; + vx = 0f; + vy = 0f; + chooseTarget(true); + trail.clear(); + retarget = 1.8f; + pause = 0f; + pulse = 0f; + + motes.clear(); + int count = 26; + for (int i = 0; i < count; i++) { + Mote m = new Mote(); + m.x = rnd.nextFloat() * w; + m.y = rnd.nextFloat() * h; + m.r = dp(0.8f + rnd.nextFloat() * 2.2f); + m.vx = (rnd.nextFloat() - 0.5f) * dp(7f); + m.vy = -dp(3f + rnd.nextFloat() * 8f); + m.phase = rnd.nextFloat() * 6.283f; + m.alpha = 0.05f + rnd.nextFloat() * 0.13f; + motes.add(m); + } + } + + private void chooseTarget(boolean initial) { + float mx = dp(110f); + float my = dp(110f); + float nx = mx + rnd.nextFloat() * Math.max(1f, w - mx * 2f); + float ny = my + rnd.nextFloat() * Math.max(1f, h - my * 2f); + if (!initial) { + int guard = 0; + while (guard++ < 8) { + float dx = nx - x; + float dy = ny - y; + if (dx * dx + dy * dy > dp(300f) * dp(300f)) { + break; + } + nx = mx + rnd.nextFloat() * Math.max(1f, w - mx * 2f); + ny = my + rnd.nextFloat() * Math.max(1f, h - my * 2f); + } + tempo = 0.75f + rnd.nextFloat() * 0.7f; + } + tx = nx; + ty = ny; + } + + @Override + void update(float dt, float t) { + updateFx(dt); + pulse = Math.max(0f, pulse - dt * 1.8f); + + for (int i = 0; i < motes.size(); i++) { + Mote m = motes.get(i); + m.phase += dt * 0.6f; + m.x += (m.vx + (float) Math.sin(m.phase) * dp(4f)) * dt; + m.y += m.vy * dt; + if (m.y < -dp(10f)) { + m.y = h + dp(10f); + m.x = rnd.nextFloat() * w; + } + if (m.x < -dp(10f)) { + m.x = w + dp(10f); + } else if (m.x > w + dp(10f)) { + m.x = -dp(10f); + } + } + + // Keep a short, lazy trail. + for (int i = trail.size() - 1; i >= 0; i--) { + Trail p = trail.get(i); + p.age += dt; + if (p.age > 0.34f) { + trail.remove(i); + } + } + if (trail.isEmpty() + || Math.abs(trail.get(trail.size() - 1).x - x) > dp(3f) + || Math.abs(trail.get(trail.size() - 1).y - y) > dp(3f)) { + Trail p = new Trail(); + p.x = x; + p.y = y; + p.age = 0f; + trail.add(p); + if (trail.size() > 26) { + trail.remove(0); + } + } + + if (pause > 0f) { + pause -= dt; + vx *= 0.82f; + vy *= 0.82f; + return; + } + + float dx = tx - x; + float dy = ty - y; + float dist = (float) Math.sqrt(dx * dx + dy * dy); + float maxSpeed = dp(760f) * tempo; + + // A little perpendicular wobble makes the dot feel hand-driven. + float wobble = (float) Math.sin(t * 3.4f) * dp(34f); + float nx = dx == 0f && dy == 0f ? 0f : dx / Math.max(1f, dist); + float ny = dx == 0f && dy == 0f ? 0f : dy / Math.max(1f, dist); + float desiredVx = dx * 3.6f - ny * wobble * 1.7f; + float desiredVy = dy * 3.6f + nx * wobble * 1.7f; + float ds = (float) Math.sqrt(desiredVx * desiredVx + desiredVy * desiredVy); + if (ds > maxSpeed) { + desiredVx = desiredVx / ds * maxSpeed; + desiredVy = desiredVy / ds * maxSpeed; + } + vx += (desiredVx - vx) * Math.min(1f, dt * 6.5f); + vy += (desiredVy - vy) * Math.min(1f, dt * 6.5f); + x += vx * dt; + y += vy * dt; + + retarget -= dt; + if (dist < dp(22f) || retarget <= 0f) { + x = Math.max(dp(80f), Math.min(w - dp(80f), x)); + y = Math.max(dp(80f), Math.min(h - dp(80f), y)); + chooseTarget(false); + retarget = 1.2f + rnd.nextFloat() * 2.2f; + pause = 0.20f + rnd.nextFloat() * 0.55f; + } + } + + @Override + void touchDown(float x, float y, float t) { + float dx = x - this.x; + float dy = y - this.y; + float dist = (float) Math.sqrt(dx * dx + dy * dy); + + ripple(x, y, 0x33FFD9D0, dp(34f)); + if (dist < dp(270f)) { + pulse = 1f; + if (dist < dp(90f)) { + // A direct hit teleports the dot, just like flicking a laser pointer. + ripple(this.x, this.y, 0x99FF8A78, dp(58f)); + sparkle(this.x, this.y, 0xFFFFC9BE, 14, dp(330f)); + chooseTarget(false); + this.x = tx; + this.y = ty; + vx = 0f; + vy = 0f; + pause = 0.04f; + retarget = 0.9f + rnd.nextFloat() * 1.2f; + ripple(this.x, this.y, 0x77FF6F5C, dp(32f)); + if (t - lastPing > 0.08f) { + lastPing = t; + sound.playChime(); + } + } else { + chooseTarget(false); + sparkle(this.x, this.y, 0xFFFFB4A6, 8, dp(190f)); + vx += dx / Math.max(1f, dist) * dp(600f); + vy += dy / Math.max(1f, dist) * dp(600f); + if (t - lastPing > 0.09f) { + lastPing = t; + sound.playPing(); + } + } + } else { + if (t - lastPing > 0.12f) { + lastPing = t; + sound.playPing(); + } + } + } + + @Override + void touchMove(float x, float y, float t) { + float dx = x - this.x; + float dy = y - this.y; + if (dx * dx + dy * dy < dp(160f) * dp(160f)) { + sparkle(this.x, this.y, 0x55FFB4A6, 1, dp(110f)); + } + } + + @Override + void draw(Canvas c, float t) { + bg.setShader(backdrop); + c.drawRect(0, 0, w, h, bg); + + drawRug(c, t); + drawMotes(c); + drawTrail(c); + drawDot(c, t); + + drawFx(c, paint); + + paint.setShader(vignette); + c.drawRect(0, 0, w, h, paint); + paint.setShader(null); + } + + private void drawRug(Canvas c, float t) { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(dp(1.2f)); + paint.setColor(0x12FFE4C8); + float spacing = dp(58f); + float shift = (t * dp(2f)) % spacing; + for (float x0 = -h; x0 < w + h; x0 += spacing) { + c.drawLine(x0 + shift, 0, x0 + shift - h, h, paint); + } + paint.setColor(0x0AFFE4C8); + for (float y0 = -w; y0 < h + w; y0 += spacing) { + c.drawLine(0, y0 - shift, w, y0 - shift + w, paint); + } + } + + private void drawMotes(Canvas c) { + paint.setStyle(Paint.Style.FILL); + for (int i = 0; i < motes.size(); i++) { + Mote m = motes.get(i); + paint.setColor(((int) (m.alpha * 255) << 24) | 0x00FFE9D2); + c.drawCircle(m.x, m.y, m.r, paint); + } + } + + private void drawTrail(Canvas c) { + paint.setStyle(Paint.Style.FILL); + for (int i = 0; i < trail.size(); i++) { + Trail p = trail.get(i); + float k = 1f - p.age / 0.34f; + if (k <= 0f) { + continue; + } + float r = dp(26f) * k; + paint.setColor(((int) (0.10f * k * 255) << 24) | 0x00FF5545); + c.drawCircle(p.x, p.y, r, paint); + } + } + + private void drawDot(Canvas c, float t) { + c.save(); + c.translate(x, y); + float breathe = 1f + (float) Math.sin(t * 7.2f) * 0.05f + pulse * 0.22f; + + c.save(); + c.scale(breathe, breathe); + paint.setShader(dotGlow); + c.drawCircle(0f, 0f, dp(150f), paint); + paint.setShader(tightGlow); + c.drawCircle(0f, 0f, dp(44f), paint); + paint.setShader(null); + c.restore(); + + paint.setStyle(Paint.Style.FILL); + paint.setColor(0xFFFF352A); + c.drawCircle(0f, 0f, dp(17f), paint); + paint.setColor(0xFFFFD7CE); + c.drawCircle(0f, 0f, dp(10f), paint); + paint.setColor(0xFFFFFFFF); + c.drawCircle(-dp(2.5f), -dp(2.5f), dp(4.6f), paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(dp(1.8f)); + paint.setColor(0xAAFF8F7E); + c.drawCircle(0f, 0f, dp(25f), paint); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setColor(0x88FF6A58); + float ray = dp(40f) + pulse * dp(10f); + c.drawLine(-ray, 0f, -dp(30f), 0f, paint); + c.drawLine(ray, 0f, dp(30f), 0f, paint); + c.drawLine(0f, -ray, 0f, -dp(30f), paint); + c.drawLine(0f, ray, 0f, dp(30f), paint); + c.restore(); + } + + @Override + void drawModeIcon(Canvas c, float cx, float cy, float size) { + paint.setStyle(Paint.Style.FILL); + paint.setColor(0x33FFF7EA); + c.drawCircle(cx, cy, size * 0.44f, paint); + paint.setColor(0xFFFFF7EA); + c.drawCircle(cx, cy, size * 0.13f, paint); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(size * 0.045f); + paint.setColor(0xCCFFF7EA); + c.drawCircle(cx, cy, size * 0.28f, paint); + } + + private static final class Trail { + float x; + float y; + float age; + } + + private static final class Mote { + float x; + float y; + float r; + float vx; + float vy; + float phase; + float alpha; + } +} diff --git a/app/src/com/local/pawplay/MainActivity.java b/app/src/com/local/pawplay/MainActivity.java new file mode 100644 index 0000000..9660ba7 --- /dev/null +++ b/app/src/com/local/pawplay/MainActivity.java @@ -0,0 +1,133 @@ +package com.local.pawplay; + +import android.app.Activity; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.view.DisplayCutout; +import android.view.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.view.WindowManager; + +/** Immersive, single-activity shell. No permissions, no network, no ads. */ +public final class MainActivity extends Activity { + private CatPlayView game; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + getWindow().setStatusBarColor(Color.TRANSPARENT); + getWindow().setNavigationBarColor(Color.TRANSPARENT); + if (Build.VERSION.SDK_INT >= 29) { + getWindow().setNavigationBarContrastEnforced(false); + getWindow().setStatusBarContrastEnforced(false); + } + if (Build.VERSION.SDK_INT >= 28) { + WindowManager.LayoutParams attrs = getWindow().getAttributes(); + attrs.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + getWindow().setAttributes(attrs); + } + if (Build.VERSION.SDK_INT >= 30) { + getWindow().setDecorFitsSystemWindows(false); + } + + game = new CatPlayView(this); + setContentView(game); + game.requestFocus(); + + View decor = getWindow().getDecorView(); + decor.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + int l = 0; + int t = 0; + int r = 0; + int b = 0; + if (Build.VERSION.SDK_INT >= 28) { + DisplayCutout cutout = insets.getDisplayCutout(); + if (cutout != null) { + l = cutout.getSafeInsetLeft(); + t = cutout.getSafeInsetTop(); + r = cutout.getSafeInsetRight(); + b = cutout.getSafeInsetBottom(); + } + } + game.setSafeInsets(l, t, r, b); + return insets; + } + }); + hideSystemUi(); + } + + private void hideSystemUi() { + if (Build.VERSION.SDK_INT >= 30) { + WindowInsetsController controller = getWindow().getInsetsController(); + if (controller != null) { + controller.hide(WindowInsets.Type.systemBars()); + controller.setSystemBarsBehavior( + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } else { + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + hideSystemUi(); + } + } + + @Override + protected void onResume() { + super.onResume(); + hideSystemUi(); + if (game != null) { + game.resumeGame(); + } + } + + @Override + protected void onPause() { + if (game != null) { + game.pauseGame(); + } + super.onPause(); + } + + @Override + protected void onDestroy() { + if (game != null) { + game.releaseGame(); + } + super.onDestroy(); + } + + @Override + public void onConfigurationChanged(android.content.res.Configuration newConfig) { + super.onConfigurationChanged(newConfig); + hideSystemUi(); + if (game != null) { + game.invalidate(); + } + } + + @Override + public void onBackPressed() { + if (game == null || !game.handleBack()) { + super.onBackPressed(); + } + } +} diff --git a/app/src/com/local/pawplay/Particle.java b/app/src/com/local/pawplay/Particle.java new file mode 100644 index 0000000..658a762 --- /dev/null +++ b/app/src/com/local/pawplay/Particle.java @@ -0,0 +1,110 @@ +package com.local.pawplay; + +import android.graphics.Canvas; +import android.graphics.Paint; + +/** A tiny pooled-ish effect sprite. Kept allocation free while drawing. */ +final class Particle { + static final int DOT = 0; + static final int STAR = 1; + static final int BUBBLE = 2; + static final int RING = 3; + static final int DOWN = 4; + + float x; + float y; + float vx; + float vy; + float life; + float maxLife; + float size; + float rot; + float spin; + float gravity; + float drag; + int color; + int kind; + + boolean update(float dt) { + life -= dt; + if (life <= 0f) { + return false; + } + vy += gravity * dt; + float d = 1f - drag * dt; + if (d < 0f) { + d = 0f; + } + vx *= d; + vy *= d; + x += vx * dt; + y += vy * dt; + rot += spin * dt; + return true; + } + + void draw(Canvas c, Paint p) { + float k = life / maxLife; + if (k < 0f) { + k = 0f; + } + int a = (int) (255 * (kind == RING ? k * 0.75f : k)); + if (a <= 3) { + return; + } + int col = (color & 0x00FFFFFF) | (a << 24); + + switch (kind) { + case STAR: { + p.setStyle(Paint.Style.STROKE); + p.setStrokeWidth(Math.max(1f, size * 0.16f)); + p.setStrokeCap(Paint.Cap.ROUND); + p.setColor(col); + float r = size * (0.5f + 0.5f * k); + float dx = (float) Math.cos(rot); + float dy = (float) Math.sin(rot); + c.drawLine(x - dx * r, y - dy * r, x + dx * r, y + dy * r, p); + c.drawLine(x - dy * r, y + dx * r, x + dy * r, y - dx * r, p); + p.setStyle(Paint.Style.FILL); + p.setColor((0xFFFFFFFF & 0x00FFFFFF) | (a << 24)); + c.drawCircle(x, y, Math.max(1f, size * 0.14f), p); + break; + } + case BUBBLE: { + p.setStyle(Paint.Style.STROKE); + p.setStrokeWidth(Math.max(1f, size * 0.09f)); + p.setColor(col); + c.drawCircle(x, y, size, p); + p.setStyle(Paint.Style.FILL); + p.setColor((0xFFFFFFFF & 0x00FFFFFF) | ((int) (a * 0.55f) << 24)); + c.drawCircle(x - size * 0.32f, y - size * 0.34f, Math.max(1f, size * 0.18f), p); + break; + } + case RING: { + float progress = 1f - k; + float radius = size * (0.25f + progress * 1.55f); + p.setStyle(Paint.Style.STROKE); + p.setStrokeWidth(Math.max(1f, size * 0.08f * (1f - progress * 0.55f))); + p.setColor(col); + c.drawCircle(x, y, radius, p); + break; + } + case DOWN: { + p.setStyle(Paint.Style.STROKE); + p.setStrokeWidth(Math.max(1f, size * 0.12f)); + p.setStrokeCap(Paint.Cap.ROUND); + p.setColor(col); + float dx = (float) Math.cos(rot) * size; + float dy = (float) Math.sin(rot) * size; + c.drawLine(x - dx, y - dy, x + dx, y + dy, p); + break; + } + default: { + p.setStyle(Paint.Style.FILL); + p.setColor(col); + c.drawCircle(x, y, size, p); + break; + } + } + } +} diff --git a/app/src/com/local/pawplay/Scene.java b/app/src/com/local/pawplay/Scene.java new file mode 100644 index 0000000..7b3c7f9 --- /dev/null +++ b/app/src/com/local/pawplay/Scene.java @@ -0,0 +1,149 @@ +package com.local.pawplay; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RadialGradient; +import android.graphics.Shader; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.Random; + +/** Base class for the three cat toys. */ +abstract class Scene { + protected final Context context; + protected final SoundKit sound; + protected final Random rnd = new Random(); + protected final ArrayList fx = new ArrayList<>(); + protected int w; + protected int h; + protected float d; + + Scene(Context context, SoundKit sound) { + this.context = context; + this.sound = sound; + } + + final void resize(int width, int height, float density) { + if (width <= 0 || height <= 0) { + return; + } + w = width; + h = height; + d = density; + onResize(); + } + + abstract int labelRes(); + + abstract int accent(); + + abstract boolean isLight(); + + abstract void onResize(); + + abstract void update(float dt, float t); + + abstract void draw(Canvas c, float t); + + void touchDown(float x, float y, float t) { + } + + void touchMove(float x, float y, float t) { + } + + abstract void drawModeIcon(Canvas c, float cx, float cy, float size); + + int hudInk() { + return isLight() ? 0xFF2B2430 : 0xFFFFF7EA; + } + + int hudBackdrop() { + return isLight() ? 0x33FFFFFF : 0x26000000; + } + + protected float dp(float value) { + return value * d; + } + + protected int withAlpha(int color, float alpha) { + int a = (int) (255 * Math.max(0f, Math.min(1f, alpha))); + return (color & 0x00FFFFFF) | (a << 24); + } + + protected void addFx(Particle p) { + if (fx.size() > 300) { + fx.remove(0); + } + fx.add(p); + } + + protected Particle particle(int kind, float x, float y, float vx, float vy, + float life, float size, int color) { + Particle p = new Particle(); + p.kind = kind; + p.x = x; + p.y = y; + p.vx = vx; + p.vy = vy; + p.life = life; + p.maxLife = life; + p.size = size; + p.color = color; + p.rot = rnd.nextFloat() * 6.28318f; + p.spin = (rnd.nextFloat() - 0.5f) * 5f; + p.drag = 0.4f; + accentParticle(p); + addFx(p); + return p; + } + + protected void accentParticle(Particle p) { + } + + protected void ripple(float x, float y, int color, float size) { + Particle p = new Particle(); + p.kind = Particle.RING; + p.x = x; + p.y = y; + p.life = 0.75f; + p.maxLife = 0.75f; + p.size = size; + p.color = color; + p.drag = 0f; + addFx(p); + } + + protected void sparkle(float x, float y, int color, int count, float power) { + for (int i = 0; i < count; i++) { + double a = rnd.nextDouble() * 6.28318; + float speed = power * (0.5f + rnd.nextFloat()); + Particle p = particle(Particle.STAR, x, y, + (float) Math.cos(a) * speed, (float) Math.sin(a) * speed, + 0.45f + rnd.nextFloat() * 0.55f, + dp(3f + rnd.nextFloat() * 4f), color); + p.gravity = dp(90f); + p.drag = 1.2f; + } + } + + protected void updateFx(float dt) { + for (Iterator it = fx.iterator(); it.hasNext(); ) { + if (!it.next().update(dt)) { + it.remove(); + } + } + } + + protected void drawFx(Canvas c, Paint p) { + for (int i = 0; i < fx.size(); i++) { + fx.get(i).draw(c, p); + } + } + + protected Shader radial(float cx, float cy, float radius, int inner, int outer) { + return new RadialGradient(cx, cy, Math.max(1f, radius), inner, outer, + Shader.TileMode.CLAMP); + } +} diff --git a/app/src/com/local/pawplay/SoundKit.java b/app/src/com/local/pawplay/SoundKit.java new file mode 100644 index 0000000..05738c3 --- /dev/null +++ b/app/src/com/local/pawplay/SoundKit.java @@ -0,0 +1,94 @@ +package com.local.pawplay; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.SoundPool; + +import java.util.Random; + +/** Four short, soft sounds generated offline in tools/generate_sounds.py. */ +final class SoundKit { + private final Random rnd = new Random(); + private SoundPool pool; + private int plop; + private int chime; + private int whoosh; + private int ping; + private boolean enabled = true; + + SoundKit(Context context) { + try { + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(); + pool = new SoundPool.Builder() + .setMaxStreams(6) + .setAudioAttributes(attrs) + .build(); + plop = pool.load(context, R.raw.plop, 1); + chime = pool.load(context, R.raw.chime, 1); + whoosh = pool.load(context, R.raw.whoosh, 1); + ping = pool.load(context, R.raw.ping, 1); + } catch (Throwable ignored) { + pool = null; + } + } + + void setEnabled(boolean on) { + enabled = on; + if (!on) { + stopAll(); + } + } + + boolean isEnabled() { + return enabled; + } + + void playPlop() { + play(plop, 0.55f, 0.95f, 0.08f); + } + + void playChime() { + play(chime, 0.32f, 0.97f, 0.05f); + } + + void playWhoosh() { + play(whoosh, 0.28f, 0.92f, 0.12f); + } + + void playPing() { + play(ping, 0.30f, 0.96f, 0.09f); + } + + private void play(int id, float volume, float rateBase, float rateSpread) { + if (!enabled || pool == null || id == 0) { + return; + } + float rate = rateBase + rnd.nextFloat() * rateSpread; + try { + pool.play(id, volume, volume, 1, 0, rate); + } catch (Throwable ignored) { + } + } + + void stopAll() { + if (pool != null) { + try { + pool.autoPause(); + } catch (Throwable ignored) { + } + } + } + + void release() { + if (pool != null) { + try { + pool.release(); + } catch (Throwable ignored) { + } + pool = null; + } + } +} diff --git a/screenshots/01-fish-portrait.png b/screenshots/01-fish-portrait.png new file mode 100644 index 0000000..b6c0028 Binary files /dev/null and b/screenshots/01-fish-portrait.png differ diff --git a/screenshots/02-panel-portrait.png b/screenshots/02-panel-portrait.png new file mode 100644 index 0000000..c48a838 Binary files /dev/null and b/screenshots/02-panel-portrait.png differ diff --git a/screenshots/03-laser-portrait.png b/screenshots/03-laser-portrait.png new file mode 100644 index 0000000..1b930bf Binary files /dev/null and b/screenshots/03-laser-portrait.png differ diff --git a/screenshots/04-feather-portrait.png b/screenshots/04-feather-portrait.png new file mode 100644 index 0000000..4bf0236 Binary files /dev/null and b/screenshots/04-feather-portrait.png differ diff --git a/screenshots/05-fish-landscape.png b/screenshots/05-fish-landscape.png new file mode 100644 index 0000000..6d1f6d4 Binary files /dev/null and b/screenshots/05-fish-landscape.png differ diff --git a/screenshots/06-panel-landscape.png b/screenshots/06-panel-landscape.png new file mode 100644 index 0000000..a363cca Binary files /dev/null and b/screenshots/06-panel-landscape.png differ diff --git a/screenshots/07-laser-landscape.png b/screenshots/07-laser-landscape.png new file mode 100644 index 0000000..f40c57f Binary files /dev/null and b/screenshots/07-laser-landscape.png differ diff --git a/screenshots/08-feather-landscape.png b/screenshots/08-feather-landscape.png new file mode 100644 index 0000000..e50fb03 Binary files /dev/null and b/screenshots/08-feather-landscape.png differ diff --git a/screenshots/09-touch-back.png b/screenshots/09-touch-back.png new file mode 100644 index 0000000..3e1ca52 Binary files /dev/null and b/screenshots/09-touch-back.png differ diff --git a/screenshots/10-panel-laser-portrait.png b/screenshots/10-panel-laser-portrait.png new file mode 100644 index 0000000..c422297 Binary files /dev/null and b/screenshots/10-panel-laser-portrait.png differ diff --git a/tools/generate_sounds.py b/tools/generate_sounds.py new file mode 100755 index 0000000..1f27dbc --- /dev/null +++ b/tools/generate_sounds.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate the four tiny, gentle WAV effects used by Paw Play. + +The sounds are intentionally soft, short and free of sharp transients so they +do not startle a cat. 16-bit mono / 44.1 kHz keeps the APK small. +""" +from __future__ import annotations + +import math +import random +import struct +import wave +from pathlib import Path + +RATE = 44100 +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "app" / "res" / "raw" + + +def write_wav(name: str, samples: list[float], peak: float = 0.45) -> None: + OUT.mkdir(parents=True, exist_ok=True) + high = max(1e-9, max(abs(s) for s in samples)) + scale = peak / high + data = b"".join( + struct.pack(" float: + t = i / RATE + total = n / RATE + a = min(1.0, t / attack) if attack > 0 else 1.0 + r = min(1.0, max(0.0, (total - t) / release)) if release > 0 else 1.0 + return a * r + + +def plop() -> list[float]: + n = int(0.30 * RATE) + out: list[float] = [] + phase = 0.0 + for i in range(n): + t = i / RATE + # Falling "bloop" with a tiny watery second partial. + freq = 250 + 620 * math.exp(-t * 18) + phase += 2 * math.pi * freq / RATE + body = math.sin(phase) * math.exp(-t * 9) + water = math.sin(phase * 2.01) * 0.16 * math.exp(-t * 13) + out.append((body + water) * envelope(i, n, 0.006, 0.07)) + return out + + +def chime() -> list[float]: + n = int(0.95 * RATE) + notes = [(1046.50, 0.00), (1318.51, 0.075), (1567.98, 0.15)] + out = [0.0] * n + for freq, delay in notes: + start = int(delay * RATE) + for i in range(start, n): + t = (i - start) / RATE + env = math.exp(-t * 5.4) * min(1.0, t / 0.006) + tone = ( + math.sin(2 * math.pi * freq * t) + + 0.28 * math.sin(2 * math.pi * freq * 2.0 * t) + + 0.08 * math.sin(2 * math.pi * freq * 3.01 * t) + ) + out[i] += tone * env * 0.33 + return [s * envelope(i, n, 0.003, 0.18) for i, s in enumerate(out)] + + +def whoosh() -> list[float]: + n = int(0.40 * RATE) + rng = random.Random(7) + out: list[float] = [] + low = 0.0 + band = 0.0 + for i in range(n): + t = i / RATE + white = rng.uniform(-1.0, 1.0) + low += (white - low) * 0.055 + band += (white - band) * 0.025 + # Airy noise that opens and closes quickly. + env = math.sin(math.pi * min(1.0, t / 0.36)) ** 1.6 + out.append((low - band * 0.55) * env * 0.9) + return out + + +def ping() -> list[float]: + n = int(0.24 * RATE) + out: list[float] = [] + for i in range(n): + t = i / RATE + env = math.exp(-t * 22) * min(1.0, t / 0.003) + tone = ( + math.sin(2 * math.pi * 1700 * t) + + 0.22 * math.sin(2 * math.pi * 2380 * t) + ) + out.append(tone * env * 0.5) + return out + + +def main() -> None: + write_wav("plop.wav", plop(), peak=0.42) + write_wav("chime.wav", chime(), peak=0.34) + write_wav("whoosh.wav", whoosh(), peak=0.25) + write_wav("ping.wav", ping(), peak=0.30) + print("wrote 4 sounds to", OUT) + + +if __name__ == "__main__": + main()