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()
|
||||||