feat: add Paw Play, a cat-friendly Android touch toy
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.1 — 2026-10-24
|
||||
|
||||
- 修复家长面板关闭后动画停留在 1.0、透明遮罩不消失,导致“继续玩”看起来没反应的问题。
|
||||
- 重做羽毛场景:更细的羽轴、可见羽枝、更饱和的配色。
|
||||
- 鱼群加入 boid 式分离 + 位置修正,减少叠在一起的情况。
|
||||
- 家长入口增加到左上 / 右上两个角落爪印,长按 2 秒。
|
||||
- 收紧家长面板高度,补充“继续玩”和“退出游戏”按钮。
|
||||
|
||||
## 1.0.0 — 2026-10-24
|
||||
|
||||
- 首个版本:鱼池、红点、羽毛三个模式。
|
||||
- 全屏沉浸式,无广告、无联网、无权限。
|
||||
- 单 Activity + 自绘 View,60fps。
|
||||
- 四段柔和音效,家长面板,返回键防误触。
|
||||
@@ -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.
|
||||
@@ -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**。
|
||||
|
||||

|
||||
|
||||
## 三个模式
|
||||
|
||||
| 鱼池 | 红点 | 羽毛 |
|
||||
| --- | --- | --- |
|
||||
| 6–7 条锦鲤在水里游动、悬停、突然加速。爪子碰到鱼会变成气泡和星星,鱼过一会儿从边缘游回来。 | 经典红点。会停、会突然窜出去;爪子拍到它时整颗点会瞬移,并留下红色余晖。 | 三根暖色羽毛在阳光里慢慢飘。挥爪会带起风,羽毛被吹开、旋转,再慢慢落回来。 |
|
||||
|  |  |  |
|
||||
|
||||
横屏是更舒服的玩法,竖屏也完整支持。全屏、无状态栏、无导航栏,屏幕常亮。
|
||||
|
||||
## 为什么这样设计(给猫,而不是给人)
|
||||
|
||||
- **猎物质感**:慢速游动 + 突然加速/停顿 + 方向变化,接近猫会追的小动物。
|
||||
- **大目标**:鱼和羽毛都做得够大,猫不需要精确点中。
|
||||
- **全屏任意触摸都有反馈**:不是只有“点中按钮”才算。多指、整只爪子拍下来都有水波、气泡或风。
|
||||
- **没有失败**:没有分数、倒计时、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 里分享照片和反馈。
|
||||
|
||||
## 截图
|
||||
|
||||
| 横屏鱼池 | 横屏家长面板 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| 横屏红点 | 横屏羽毛 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| 竖屏家长面板 | 触摸反馈 / 返回提示 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 424 KiB |
@@ -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()
|
||||