feat: add Paw Play, a cat-friendly Android touch toy

This commit is contained in:
2026-10-24 22:10:58 +08:00
commit fee35d678b
36 changed files with 3459 additions and 0 deletions
+22
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 1.0.1 — 2026-10-24
- 修复家长面板关闭后动画停留在 1.0、透明遮罩不消失,导致“继续玩”看起来没反应的问题。
- 重做羽毛场景:更细的羽轴、可见羽枝、更饱和的配色。
- 鱼群加入 boid 式分离 + 位置修正,减少叠在一起的情况。
- 家长入口增加到左上 / 右上两个角落爪印,长按 2 秒。
- 收紧家长面板高度,补充“继续玩”和“退出游戏”按钮。
## 1.0.0 — 2026-10-24
- 首个版本:鱼池、红点、羽毛三个模式。
- 全屏沉浸式,无广告、无联网、无权限。
- 单 Activity + 自绘 View,60fps。
- 四段柔和音效,家长面板,返回键防误触。
+21
View File
@@ -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.
+203
View File
@@ -0,0 +1,203 @@
# 猫爪游乐场 · Paw Play
给猫猫玩的 Android 触摸玩具。不是让猫盯着看的视频,而是会对爪子有回应的三个小游戏。
> 在线仓库:<https://git.k1412.top/wuyang/pawplay>
> 下载 APK:<https://git.k1412.top/wuyang/pawplay/releases>
> 开源协议: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:从 <https://git.k1412.top/wuyang/pawplay/releases> 下载
安装:
```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) |
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.local.pawplay"
android:versionCode="2"
android:versionName="1.0.1">
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="35" />
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
<application
android:allowBackup="false"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.PawPlay">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|density"
android:enableOnBackInvokedCallback="false"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:screenOrientation="sensor">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Executable
+65
View File
@@ -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"
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFF7EA"
android:pathData="M35,51.2 C30.4,51.2 26.7,46.7 26.7,41.2 C26.7,35.7 30.4,31.2 35,31.2 C39.6,31.2 43.3,35.7 43.3,41.2 C43.3,46.7 39.6,51.2 35,51.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M49,46.8 C44.4,46.8 40.7,42.3 40.7,36.8 C40.7,31.3 44.4,26.8 49,26.8 C53.6,26.8 57.3,31.3 57.3,36.8 C57.3,42.3 53.6,46.8 49,46.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M63,46.8 C58.4,46.8 54.7,42.3 54.7,36.8 C54.7,31.3 58.4,26.8 63,26.8 C67.6,26.8 71.3,31.3 71.3,36.8 C71.3,42.3 67.6,46.8 63,46.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M77,51.2 C72.4,51.2 68.7,46.7 68.7,41.2 C68.7,35.7 72.4,31.2 77,31.2 C81.6,31.2 85.3,35.7 85.3,41.2 C85.3,46.7 81.6,51.2 77,51.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M56,55 C45.5,55 37.5,62.8 37.5,72.4 C37.5,82.7 46.4,88.5 56,88.5 C65.6,88.5 74.5,82.7 74.5,72.4 C74.5,62.8 66.5,55 56,55 Z" />
</vector>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF6F59"
android:pathData="M26,2 L82,2 C95.3,2 106,12.7 106,26 L106,82 C106,95.3 95.3,106 82,106 L26,106 C12.7,106 2,95.3 2,82 L2,26 C2,12.7 12.7,2 26,2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M35,53.2 C30.4,53.2 26.7,48.7 26.7,43.2 C26.7,37.7 30.4,33.2 35,33.2 C39.6,33.2 43.3,37.7 43.3,43.2 C43.3,48.7 39.6,53.2 35,53.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M49,48.8 C44.4,48.8 40.7,44.3 40.7,38.8 C40.7,33.3 44.4,28.8 49,28.8 C53.6,28.8 57.3,33.3 57.3,38.8 C57.3,44.3 53.6,48.8 49,48.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M63,48.8 C58.4,48.8 54.7,44.3 54.7,38.8 C54.7,33.3 58.4,28.8 63,28.8 C67.6,28.8 71.3,33.3 71.3,38.8 C71.3,44.3 67.6,48.8 63,48.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M77,53.2 C72.4,53.2 68.7,48.7 68.7,43.2 C68.7,37.7 72.4,33.2 77,33.2 C81.6,33.2 85.3,37.7 85.3,43.2 C85.3,48.7 81.6,53.2 77,53.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M56,57 C45.5,57 37.5,64.8 37.5,74.4 C37.5,84.7 46.4,90.5 56,90.5 C65.6,90.5 74.5,84.7 74.5,74.4 C74.5,64.8 66.5,57 56,57 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_legacy"
android:inset="0dp" />
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_bg">#FF6F59</color>
<color name="icon_fg">#FFF7EA</color>
</resources>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">猫爪游乐场</string>
<string name="hint_title">猫爪游乐场</string>
<string name="hint_sub">把平板放平,让猫猫开抓</string>
<string name="parent_hint">家长设置</string>
<string name="parent_tip">建议贴钢化膜,陪猫猫玩 5–10 分钟就休息。</string>
<string name="exit">退出游戏</string>
<string name="close">关闭</string>
<string name="sound">轻柔音效</string>
<string name="mode_fish">鱼池</string>
<string name="mode_laser">红点</string>
<string name="mode_feather">羽毛</string>
<string name="tap_hint">轻触画面 · 猫猫开玩</string>
<string name="hold_hint">长按角落爪印 2 秒</string>
<string name="sound_on">音效已打开</string>
<string name="sound_off">音效已关闭</string>
<string name="exit_hint">再按一次返回键可退出</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PawPlay" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowBackground">#082A34</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:colorAccent">#FF6F59</item>
<item name="android:fontFamily">sans</item>
</style>
</resources>
File diff suppressed because it is too large Load Diff
+417
View File
@@ -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<Feather> 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;
}
}
}
+611
View File
@@ -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<Fish> fishes = new ArrayList<>();
private final ArrayList<Bubble> 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;
}
}
+370
View File
@@ -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> trail = new ArrayList<>();
private final ArrayList<Mote> 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;
}
}
+133
View File
@@ -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();
}
}
}
+110
View File
@@ -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;
}
}
}
}
+149
View File
@@ -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<Particle> 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<Particle> 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);
}
}
+94
View File
@@ -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;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

+116
View File
@@ -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("<h", int(max(-1.0, min(1.0, s * scale)) * 32767))
for s in samples
)
with wave.open(str(OUT / name), "wb") as fh:
fh.setnchannels(1)
fh.setsampwidth(2)
fh.setframerate(RATE)
fh.writeframes(data)
def envelope(i: int, n: int, attack: float = 0.01, release: float = 0.08) -> 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()