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

This commit is contained in:
2026-10-24 22:10:58 +08:00
commit fee35d678b
36 changed files with 3459 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.local.pawplay"
android:versionCode="2"
android:versionName="1.0.1">
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="35" />
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
<application
android:allowBackup="false"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.PawPlay">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|density"
android:enableOnBackInvokedCallback="false"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:screenOrientation="sensor">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Executable
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Build Paw Play without Gradle. Android 35 platform + build-tools 35's d8.jar.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "$0")" && pwd)
TOOLCHAIN=${TOOLCHAIN:-/root/Documents/Codex/2026-10-12/ub7f-2s8z-chatgpt-1510/work/toolchain}
ANDROID_JAR=${ANDROID_JAR:-$TOOLCHAIN/android-35/android.jar}
RESOURCE_JAR=${RESOURCE_JAR:-$TOOLCHAIN/android-34/android.jar}
D8_JAR=${D8_JAR:-$TOOLCHAIN/android-15/lib/d8.jar}
AAPT2=${AAPT2:-aapt2}
KEYSTORE=${KEYSTORE:-$ROOT/keystore/catplay.jks}
STOREPASS=${STOREPASS:-catplay-local}
KEYPASS=${KEYPASS:-catplay-local}
ALIAS=${ALIAS:-catplay}
BUILD_DIR=${BUILD_DIR:-$ROOT/build}
APK_OUT=${APK_OUT:-$ROOT/猫爪游乐场.apk}
for f in "$ANDROID_JAR" "$RESOURCE_JAR" "$D8_JAR"; do
[[ -f "$f" ]] || { echo "missing toolchain file: $f" >&2; exit 1; }
done
if [[ ! -f "$KEYSTORE" ]]; then
mkdir -p "$(dirname "$KEYSTORE")"
keytool -genkeypair -v \
-keystore "$KEYSTORE" -storetype PKCS12 \
-alias "$ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \
-storepass "$STOREPASS" -keypass "$KEYPASS" \
-dname "CN=PawPlay, OU=Local, O=PawPlay, L=Local, ST=Local, C=CN" >/dev/null
fi
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR/gen" "$BUILD_DIR/classes" "$BUILD_DIR/dex"
"$AAPT2" compile --dir "$ROOT/res" -o "$BUILD_DIR/res.zip"
"$AAPT2" link -o "$BUILD_DIR/unsigned.apk" \
-I "$RESOURCE_JAR" \
--manifest "$ROOT/AndroidManifest.xml" \
--java "$BUILD_DIR/gen" \
"$BUILD_DIR/res.zip"
SRC=$(find "$ROOT/src" -name '*.java' -print)
javac -source 8 -target 8 -Xlint:-options -encoding UTF-8 \
-cp "$ANDROID_JAR" -d "$BUILD_DIR/classes" \
$SRC "$BUILD_DIR/gen/com/local/pawplay/R.java"
jar cf "$BUILD_DIR/classes.jar" -C "$BUILD_DIR/classes" .
java -cp "$D8_JAR" com.android.tools.r8.D8 \
--min-api 26 --lib "$ANDROID_JAR" \
--output "$BUILD_DIR/dex" "$BUILD_DIR/classes.jar"
python3 - "$BUILD_DIR" <<'PY'
import sys, pathlib, zipfile
p = pathlib.Path(sys.argv[1])
with zipfile.ZipFile(p / 'unsigned.apk', 'a', compression=zipfile.ZIP_DEFLATED) as z:
z.write(p / 'dex/classes.dex', 'classes.dex')
PY
rm -f "$APK_OUT"
apksigner sign \
--ks "$KEYSTORE" --ks-key-alias "$ALIAS" \
--ks-pass "pass:$STOREPASS" --key-pass "pass:$KEYPASS" \
--out "$APK_OUT" "$BUILD_DIR/unsigned.apk"
apksigner verify --verbose "$APK_OUT" | sed -n '1,12p'
echo "Built: $APK_OUT"
ls -lh "$APK_OUT"
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFF7EA"
android:pathData="M35,51.2 C30.4,51.2 26.7,46.7 26.7,41.2 C26.7,35.7 30.4,31.2 35,31.2 C39.6,31.2 43.3,35.7 43.3,41.2 C43.3,46.7 39.6,51.2 35,51.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M49,46.8 C44.4,46.8 40.7,42.3 40.7,36.8 C40.7,31.3 44.4,26.8 49,26.8 C53.6,26.8 57.3,31.3 57.3,36.8 C57.3,42.3 53.6,46.8 49,46.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M63,46.8 C58.4,46.8 54.7,42.3 54.7,36.8 C54.7,31.3 58.4,26.8 63,26.8 C67.6,26.8 71.3,31.3 71.3,36.8 C71.3,42.3 67.6,46.8 63,46.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M77,51.2 C72.4,51.2 68.7,46.7 68.7,41.2 C68.7,35.7 72.4,31.2 77,31.2 C81.6,31.2 85.3,35.7 85.3,41.2 C85.3,46.7 81.6,51.2 77,51.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M56,55 C45.5,55 37.5,62.8 37.5,72.4 C37.5,82.7 46.4,88.5 56,88.5 C65.6,88.5 74.5,82.7 74.5,72.4 C74.5,62.8 66.5,55 56,55 Z" />
</vector>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF6F59"
android:pathData="M26,2 L82,2 C95.3,2 106,12.7 106,26 L106,82 C106,95.3 95.3,106 82,106 L26,106 C12.7,106 2,95.3 2,82 L2,26 C2,12.7 12.7,2 26,2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M35,53.2 C30.4,53.2 26.7,48.7 26.7,43.2 C26.7,37.7 30.4,33.2 35,33.2 C39.6,33.2 43.3,37.7 43.3,43.2 C43.3,48.7 39.6,53.2 35,53.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M49,48.8 C44.4,48.8 40.7,44.3 40.7,38.8 C40.7,33.3 44.4,28.8 49,28.8 C53.6,28.8 57.3,33.3 57.3,38.8 C57.3,44.3 53.6,48.8 49,48.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M63,48.8 C58.4,48.8 54.7,44.3 54.7,38.8 C54.7,33.3 58.4,28.8 63,28.8 C67.6,28.8 71.3,33.3 71.3,38.8 C71.3,44.3 67.6,48.8 63,48.8 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M77,53.2 C72.4,53.2 68.7,48.7 68.7,43.2 C68.7,37.7 72.4,33.2 77,33.2 C81.6,33.2 85.3,37.7 85.3,43.2 C85.3,48.7 81.6,53.2 77,53.2 Z" />
<path
android:fillColor="#FFF7EA"
android:pathData="M56,57 C45.5,57 37.5,64.8 37.5,74.4 C37.5,84.7 46.4,90.5 56,90.5 C65.6,90.5 74.5,84.7 74.5,74.4 C74.5,64.8 66.5,57 56,57 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_legacy"
android:inset="0dp" />
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_bg">#FF6F59</color>
<color name="icon_fg">#FFF7EA</color>
</resources>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">猫爪游乐场</string>
<string name="hint_title">猫爪游乐场</string>
<string name="hint_sub">把平板放平,让猫猫开抓</string>
<string name="parent_hint">家长设置</string>
<string name="parent_tip">建议贴钢化膜,陪猫猫玩 5–10 分钟就休息。</string>
<string name="exit">退出游戏</string>
<string name="close">关闭</string>
<string name="sound">轻柔音效</string>
<string name="mode_fish">鱼池</string>
<string name="mode_laser">红点</string>
<string name="mode_feather">羽毛</string>
<string name="tap_hint">轻触画面 · 猫猫开玩</string>
<string name="hold_hint">长按角落爪印 2 秒</string>
<string name="sound_on">音效已打开</string>
<string name="sound_off">音效已关闭</string>
<string name="exit_hint">再按一次返回键可退出</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PawPlay" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowBackground">#082A34</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:colorAccent">#FF6F59</item>
<item name="android:fontFamily">sans</item>
</style>
</resources>
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
package com.local.pawplay;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.Shader;
import java.util.ArrayList;
/**
* A sunlit, warm room with slow drifting quill feathers. Touches make a little
* breeze; feathers tumble away and then settle again.
*/
final class FeatherScene extends Scene {
private static final int[] FEATHER_COLORS = {
0xFF147B76, 0xFFD4543F, 0xFFC08628
};
private final Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final ArrayList<Feather> feathers = new ArrayList<>();
private float[] grainX;
private float[] grainY;
private float[] grainR;
private Shader backdrop;
private Shader sun;
private Shader vignette;
private float lastFx = -10f;
private float lastWhoosh = -10f;
private float windX = -999f;
private float windY = -999f;
private float windAge = 10f;
FeatherScene(Context context, SoundKit sound) {
super(context, sound);
}
@Override
int labelRes() {
return R.string.mode_feather;
}
@Override
int accent() {
return 0xFFC08628;
}
@Override
boolean isLight() {
return true;
}
@Override
void onResize() {
backdrop = new LinearGradient(0, 0, w * 0.35f, h,
new int[]{0xFFFFFAEE, 0xFFF7E8D2, 0xFFEDD5B8},
new float[]{0f, 0.58f, 1f}, Shader.TileMode.CLAMP);
sun = new RadialGradient(w * 0.76f, h * 0.10f, Math.max(w, h) * 0.58f,
new int[]{0x66FFF4D2, 0x22FFF4D2, 0x00FFF4D2},
new float[]{0f, 0.38f, 1f}, Shader.TileMode.CLAMP);
vignette = new RadialGradient(w * 0.5f, h * 0.48f, Math.max(w, h) * 0.76f,
new int[]{0x00000000, 0x00000000, 0x266B4A32},
new float[]{0f, 0.58f, 1f}, Shader.TileMode.CLAMP);
feathers.clear();
int count = 3;
for (int i = 0; i < count; i++) {
Feather f = new Feather();
f.size = dp(64f + i * 7f + rnd.nextFloat() * 6f);
f.color = FEATHER_COLORS[i % FEATHER_COLORS.length];
f.x = w * (0.24f + i * 0.26f) + (rnd.nextFloat() - 0.5f) * w * 0.08f;
f.y = h * (0.38f + i * 0.09f) + (rnd.nextFloat() - 0.5f) * h * 0.06f;
f.angle = -32f + i * 22f + (rnd.nextFloat() - 0.5f) * 18f;
f.spin = (rnd.nextFloat() - 0.5f) * 7f;
f.phase = rnd.nextFloat() * 6.283f;
f.vx = (rnd.nextFloat() - 0.5f) * dp(10f);
f.vy = (rnd.nextFloat() - 0.5f) * dp(8f);
f.build();
feathers.add(f);
}
int grains = 140;
grainX = new float[grains];
grainY = new float[grains];
grainR = new float[grains];
for (int i = 0; i < grains; i++) {
grainX[i] = rnd.nextFloat() * w;
grainY[i] = rnd.nextFloat() * h;
grainR[i] = dp(0.7f + rnd.nextFloat() * 1.8f);
}
}
@Override
void update(float dt, float t) {
updateFx(dt);
windAge += dt;
for (int i = 0; i < feathers.size(); i++) {
Feather f = feathers.get(i);
f.phase += dt * (0.65f + i * 0.06f);
float targetVx = (float) Math.sin(t * 0.47f + f.phase) * dp(9f);
float targetVy = (float) Math.cos(t * 0.38f + f.phase * 1.3f) * dp(6f)
- dp(2f);
f.vx += (targetVx - f.vx) * Math.min(1f, dt * 0.9f);
f.vy += (targetVy - f.vy) * Math.min(1f, dt * 0.9f);
if (windAge < 0.65f) {
float dx = f.x - windX;
float dy = f.y - windY;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
if (dist < dp(320f) && dist > 1f) {
float power = (1f - dist / dp(320f)) * dp(950f);
f.vx += dx / dist * power * dt;
f.vy += dy / dist * power * dt - dp(90f) * dt;
f.spin += (rnd.nextFloat() - 0.5f) * 80f * dt;
}
}
f.x += f.vx * dt;
f.y += f.vy * dt;
f.angle += f.spin * dt;
f.spin *= Math.max(0f, 1f - dt * 0.55f);
f.vx *= Math.max(0f, 1f - dt * 0.20f);
f.vy *= Math.max(0f, 1f - dt * 0.20f);
float margin = f.size * 1.2f;
if (f.x < margin) {
f.x = margin;
f.vx = Math.abs(f.vx) * 0.7f + dp(7f);
f.spin += 7f;
} else if (f.x > w - margin) {
f.x = w - margin;
f.vx = -Math.abs(f.vx) * 0.7f - dp(7f);
f.spin -= 7f;
}
if (f.y < margin) {
f.y = margin;
f.vy = Math.abs(f.vy) * 0.7f + dp(4f);
} else if (f.y > h - margin) {
f.y = h - margin;
f.vy = -Math.abs(f.vy) * 0.7f - dp(4f);
f.spin += 3f;
}
}
}
@Override
void touchDown(float x, float y, float t) {
windX = x;
windY = y;
windAge = 0f;
ripple(x, y, 0x552B2430, dp(42f));
for (int i = 0; i < 11; i++) {
double a = rnd.nextDouble() * 6.28318;
float speed = dp(30f + rnd.nextFloat() * 120f);
Particle p = particle(Particle.DOWN, x, y,
(float) Math.cos(a) * speed, (float) Math.sin(a) * speed,
0.7f + rnd.nextFloat() * 0.8f,
dp(3f + rnd.nextFloat() * 5f),
i % 3 == 0 ? 0xFFD4543F : 0xFF8A6A4A);
p.gravity = dp(40f);
p.drag = 1.5f;
}
for (int i = 0; i < feathers.size(); i++) {
Feather f = feathers.get(i);
float dx = f.x - x;
float dy = f.y - y;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
if (dist < f.size * 2.7f) {
sparkle(f.x, f.y, f.color, 5, dp(140f));
if (dist < f.size * 1.25f) {
ripple(f.x, f.y, 0x44FFFFFF, dp(40f));
}
}
}
if (t - lastWhoosh > 0.18f) {
lastWhoosh = t;
sound.playWhoosh();
}
}
@Override
void touchMove(float x, float y, float t) {
windX = x;
windY = y;
windAge = 0f;
if (t - lastFx > 0.035f) {
lastFx = t;
Particle p = particle(Particle.DOWN, x, y,
(rnd.nextFloat() - 0.5f) * dp(80f),
(rnd.nextFloat() - 0.5f) * dp(80f) - dp(10f),
0.45f + rnd.nextFloat() * 0.4f,
dp(1.5f + rnd.nextFloat() * 2.5f), 0xFF8A6A4A);
p.gravity = dp(50f);
p.drag = 1.8f;
}
}
@Override
void draw(Canvas c, float t) {
bg.setShader(backdrop);
c.drawRect(0, 0, w, h, bg);
paint.setShader(sun);
c.drawRect(0, 0, w, h, paint);
paint.setShader(null);
drawGrain(c);
drawAir(c, t);
for (int i = 0; i < feathers.size(); i++) {
drawFeather(c, feathers.get(i));
}
drawFx(c, paint);
if (windAge < 0.4f) {
float a = (1f - windAge / 0.4f) * 0.18f;
glow.setShader(new RadialGradient(windX, windY, dp(150f),
new int[]{withAlpha(0xFFFFFFFF, a), 0x00FFFFFF},
new float[]{0f, 1f}, Shader.TileMode.CLAMP));
c.drawCircle(windX, windY, dp(150f), glow);
glow.setShader(null);
}
paint.setShader(vignette);
c.drawRect(0, 0, w, h, paint);
paint.setShader(null);
}
private void drawGrain(Canvas c) {
paint.setStyle(Paint.Style.FILL);
for (int i = 0; i < grainX.length; i++) {
paint.setColor(i % 4 == 0 ? 0x12FFFFFF : 0x0C6B4A32);
c.drawCircle(grainX[i], grainY[i], grainR[i], paint);
}
}
private void drawAir(Canvas c, float t) {
paint.setStyle(Paint.Style.FILL);
for (int i = 0; i < 18; i++) {
float x = (w * (0.08f + i * 0.051f)
+ (float) Math.sin(t * 0.16f + i * 1.7f) * dp(26f)) % w;
float y = (h * (0.12f + i * 0.047f)
+ (float) Math.cos(t * 0.12f + i * 1.1f) * dp(30f)) % h;
if (x < 0) {
x += w;
}
if (y < 0) {
y += h;
}
paint.setColor(i % 3 == 0 ? 0x16FFF8E8 : 0x128A6A4A);
c.drawCircle(x, y, dp(1.1f + (i % 3) * 0.5f), paint);
}
}
private void drawFeather(Canvas c, Feather f) {
float H = f.size * 1.38f;
float W = f.size * 0.40f;
float radians = (float) Math.toRadians(f.angle);
float shX = (float) Math.cos(radians + 2.2f) * dp(13f);
float shY = (float) Math.sin(radians + 2.2f) * dp(13f);
// Soft cast shadow.
c.save();
c.translate(f.x + shX, f.y + shY);
c.rotate(f.angle);
paint.setStyle(Paint.Style.FILL);
paint.setShader(null);
paint.setColor(0x1A6B4A32);
c.drawPath(f.vane, paint);
c.restore();
c.save();
c.translate(f.x, f.y);
c.rotate(f.angle);
paint.setStyle(Paint.Style.FILL);
paint.setAlpha(255);
paint.setShader(f.gradient);
c.drawPath(f.vane, paint);
paint.setShader(null);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(dp(1f));
paint.setColor(0x242B2430);
c.drawPath(f.vane, paint);
// Barbs, clipped inside the vane so the edge stays crisp.
c.save();
c.clipPath(f.vane);
int barbs = 22;
for (int i = 1; i < barbs; i++) {
float u = i / (float) barbs;
float y = -H * 0.88f + H * 1.62f * u;
float width = W * 0.94f * Feather.profile(u);
paint.setStrokeWidth(dp(0.8f));
paint.setColor(0x55FFF8EA);
c.drawLine(0f, y, -width * 0.94f, y + H * 0.055f, paint);
c.drawLine(0f, y + H * 0.01f, width * 0.94f, y + H * 0.065f, paint);
paint.setColor(0x1A5A3820);
c.drawLine(0f, y, -width * 0.86f, y + H * 0.045f, paint);
c.drawLine(0f, y + H * 0.01f, width * 0.86f, y + H * 0.055f, paint);
}
c.restore();
// Rachis and a bare quill.
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeWidth(dp(3.4f));
paint.setColor(0xDDFFF8E9);
c.drawLine(0f, -H * 0.95f, 0f, H * 0.90f, paint);
paint.setStrokeWidth(dp(1.1f));
paint.setColor(0x99322418);
c.drawLine(0f, -H * 0.90f, 0f, H * 0.86f, paint);
paint.setStrokeWidth(dp(3.2f));
paint.setColor(0xE6FFF4DF);
c.drawLine(0f, H * 0.80f, 0f, H * 1.13f, paint);
paint.setStrokeWidth(dp(1f));
paint.setColor(0x664A3522);
c.drawLine(0f, H * 0.84f, 0f, H * 1.08f, paint);
c.restore();
}
@Override
void drawModeIcon(Canvas c, float cx, float cy, float size) {
c.save();
c.translate(cx, cy);
c.rotate(-34f);
float H = size * 0.52f;
paint.setStyle(Paint.Style.FILL);
paint.setColor(0xFFFFF7EA);
Path p = new Path();
p.moveTo(0f, -H);
p.cubicTo(-size * 0.17f, -H * 0.45f, -size * 0.14f, H * 0.28f,
0f, H * 0.62f);
p.cubicTo(size * 0.13f, H * 0.26f, size * 0.16f, -H * 0.48f,
0f, -H);
p.close();
c.drawPath(p, paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(Math.max(1f, size * 0.045f));
paint.setColor(0xFFFFF7EA);
c.drawLine(0f, -H * 0.95f, 0f, H * 0.98f, paint);
paint.setStrokeWidth(Math.max(1f, size * 0.03f));
for (int i = 0; i < 3; i++) {
float yy = -H * 0.28f + i * H * 0.30f;
float xx = size * 0.15f * (1f - i * 0.18f);
c.drawLine(0f, yy, -xx, yy + H * 0.16f, paint);
c.drawLine(0f, yy + H * 0.04f, xx, yy + H * 0.20f, paint);
}
c.restore();
}
private static final class Feather {
float x;
float y;
float vx;
float vy;
float angle;
float spin;
float size;
float phase;
int color;
final Path vane = new Path();
Shader gradient;
static float profile(float u) {
if (u <= 0f || u >= 1f) {
return 0f;
}
double raw = Math.pow(u, 0.35) * Math.pow(1.0 - u, 0.55);
return (float) (raw / 0.5469);
}
void build() {
float H = size * 1.38f;
float W = size * 0.40f;
int steps = 30;
vane.reset();
vane.moveTo(0f, -H * 0.95f);
for (int i = 1; i <= steps; i++) {
float u = i / (float) steps;
float y = -H * 0.88f + H * 1.62f * u;
float x = -W * profile(u) * (0.97f + 0.03f * (float) Math.sin(i * 2.4f));
vane.lineTo(x, y);
}
vane.lineTo(0f, H * 0.88f);
for (int i = steps; i >= 1; i--) {
float u = i / (float) steps;
float y = -H * 0.88f + H * 1.62f * u;
float x = W * profile(u) * (0.97f + 0.03f * (float) Math.sin(i * 2.1f + 1.3f));
vane.lineTo(x, y);
}
vane.close();
int tip = mix(color, 0xFFFFF6E4, 0.46f);
int base = mix(color, 0xFF2B1A12, 0.10f);
gradient = new LinearGradient(0f, -H * 0.95f, 0f, H * 0.9f,
new int[]{tip, color, base},
new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP);
}
static int mix(int a, int b, float t) {
int r = (int) (Color.red(a) * (1 - t) + Color.red(b) * t);
int g = (int) (Color.green(a) * (1 - t) + Color.green(b) * t);
int bl = (int) (Color.blue(a) * (1 - t) + Color.blue(b) * t);
return 0xFF000000 | (r << 16) | (g << 8) | bl;
}
}
}
+611
View File
@@ -0,0 +1,611 @@
package com.local.pawplay;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.Shader;
import java.util.ArrayList;
/**
* A calm koi pond. Fish wander, dart, and flee from a paw. Direct hits turn
* into a puff of bubbles and the fish quietly returns a moment later.
*/
final class FishScene extends Scene {
private static final int[] FISH_COLORS = {
0xFFFF7A59, 0xFFFFC857, 0xFFFFF1DC,
0xFF66E0D4, 0xFFFF9FB2, 0xFF7FB7FF,
0xFFFFA45B
};
private final Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final ArrayList<Fish> fishes = new ArrayList<>();
private final ArrayList<Bubble> bubbles = new ArrayList<>();
private float[] speckX;
private float[] speckY;
private float[] speckR;
private final Path wave = new Path();
private final Path rays = new Path();
private Shader water;
private Shader vignette;
private float lastFxTime = -10f;
private float lastPlop = -10f;
private float touchGlowX = -999f;
private float touchGlowY = -999f;
private float touchGlowAge = 10f;
FishScene(Context context, SoundKit sound) {
super(context, sound);
}
@Override
int labelRes() {
return R.string.mode_fish;
}
@Override
int accent() {
return 0xFF0E7C86;
}
@Override
boolean isLight() {
return false;
}
@Override
void onResize() {
water = new LinearGradient(0, 0, w * 0.28f, h,
new int[]{0xFF0C4653, 0xFF0A3742, 0xFF061E27},
new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP);
vignette = new RadialGradient(w * 0.5f, h * 0.42f, Math.max(w, h) * 0.72f,
new int[]{0x00000000, 0x00000000, 0x7A000000},
new float[]{0f, 0.52f, 1f}, Shader.TileMode.CLAMP);
fishes.clear();
int count = 6;
if (w > h) {
count = 7;
}
for (int i = 0; i < count; i++) {
Fish f = new Fish();
f.size = dp(50f + rnd.nextFloat() * 20f);
f.color = FISH_COLORS[i % FISH_COLORS.length];
f.pattern = rnd.nextBoolean() ? 1 : 0;
placeFish(f, true);
for (int attempt = 0; attempt < 70; attempt++) {
boolean clear = true;
for (int j = 0; j < fishes.size(); j++) {
Fish other = fishes.get(j);
float dx = f.x - other.x;
float dy = f.y - other.y;
float min = (f.size + other.size) * 1.75f;
if (dx * dx + dy * dy < min * min) {
clear = false;
break;
}
}
if (clear) {
break;
}
placeFish(f, true);
}
fishes.add(f);
}
speckX = new float[90];
speckY = new float[90];
speckR = new float[90];
for (int i = 0; i < speckX.length; i++) {
speckX[i] = rnd.nextFloat() * w;
speckY[i] = rnd.nextFloat() * h;
speckR[i] = dp(0.6f + rnd.nextFloat() * 1.9f);
}
bubbles.clear();
int bubbleCount = Math.max(18, (int) (w / dp(80f)));
for (int i = 0; i < bubbleCount; i++) {
Bubble b = new Bubble();
b.x = rnd.nextFloat() * w;
b.y = rnd.nextFloat() * h;
b.r = dp(1.2f + rnd.nextFloat() * 3.8f);
b.speed = dp(8f + rnd.nextFloat() * 26f);
b.sway = dp(4f + rnd.nextFloat() * 16f);
b.phase = rnd.nextFloat() * 6.283f;
b.alpha = 0.10f + rnd.nextFloat() * 0.22f;
bubbles.add(b);
}
}
private void placeFish(Fish f, boolean initial) {
float m = f.size * 2.2f + dp(18f);
if (initial) {
f.x = m + rnd.nextFloat() * Math.max(1f, w - m * 2f);
f.y = m + rnd.nextFloat() * Math.max(1f, h - m * 2f);
} else {
int side = rnd.nextInt(4);
if (side == 0) {
f.x = m;
f.y = m + rnd.nextFloat() * Math.max(1f, h - m * 2f);
} else if (side == 1) {
f.x = w - m;
f.y = m + rnd.nextFloat() * Math.max(1f, h - m * 2f);
} else if (side == 2) {
f.x = m + rnd.nextFloat() * Math.max(1f, w - m * 2f);
f.y = m;
} else {
f.x = m + rnd.nextFloat() * Math.max(1f, w - m * 2f);
f.y = h - m;
}
}
f.angle = rnd.nextFloat() * 6.283f;
f.baseSpeed = dp(55f + rnd.nextFloat() * 48f);
f.vx = (float) Math.cos(f.angle) * f.baseSpeed;
f.vy = (float) Math.sin(f.angle) * f.baseSpeed;
f.phase = rnd.nextFloat() * 6.283f;
f.retarget = 0f;
f.boost = 0f;
f.hidden = false;
f.respawn = 0f;
f.blink = 2f + rnd.nextFloat() * 5f;
f.targetX = f.x;
f.targetY = f.y;
}
@Override
void update(float dt, float t) {
updateFx(dt);
touchGlowAge += dt;
for (int i = 0; i < fishes.size(); i++) {
Fish f = fishes.get(i);
if (f.hidden) {
f.respawn -= dt;
if (f.respawn <= 0f) {
f.x = -dp(100f);
f.y = dp(100f) + rnd.nextFloat() * Math.max(1f, h - dp(200f));
f.angle = 0f;
f.vx = dp(120f);
f.vy = 0f;
f.hidden = false;
f.blink = 1f + rnd.nextFloat() * 2f;
}
continue;
}
f.blink -= dt;
if (f.blink < -0.16f) {
f.blink = 2.5f + rnd.nextFloat() * 5.5f;
}
f.retarget -= dt;
float dx = f.targetX - f.x;
float dy = f.targetY - f.y;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
if (f.retarget <= 0f || dist < dp(55f)) {
float marginX = f.size * 2.0f + dp(24f);
float marginY = f.size * 2.0f + dp(24f);
f.targetX = marginX + rnd.nextFloat() * Math.max(1f, w - marginX * 2f);
f.targetY = marginY + rnd.nextFloat() * Math.max(1f, h - marginY * 2f);
f.retarget = 2.2f + rnd.nextFloat() * 3.8f;
}
if (rnd.nextFloat() < dt * 0.13f) {
f.boost = Math.max(f.boost, 0.8f + rnd.nextFloat() * 0.7f);
}
f.boost = Math.max(0f, f.boost - dt * 0.8f);
float desired = (float) Math.atan2(f.targetY - f.y, f.targetX - f.x);
float speed = f.baseSpeed * (1f + f.boost * 1.7f);
float tx = (float) Math.cos(desired) * speed;
float ty = (float) Math.sin(desired) * speed;
float follow = f.boost > 0.1f ? 2.1f : 0.9f;
f.vx += (tx - f.vx) * Math.min(1f, dt * follow);
f.vy += (ty - f.vy) * Math.min(1f, dt * follow);
// Flee from the most recent paw touch.
if (touchGlowAge < 0.55f) {
float sx = f.x - touchGlowX;
float sy = f.y - touchGlowY;
float sd = (float) Math.sqrt(sx * sx + sy * sy);
if (sd < dp(190f) && sd > 1f) {
float push = dp(360f) * (1f - sd / dp(190f));
f.vx += sx / sd * push * dt * 9f;
f.vy += sy / sd * push * dt * 9f;
f.targetX = f.x + sx * 2f;
f.targetY = f.y + sy * 2f;
f.retarget = 1.2f;
}
}
// Gentle boid-like separation keeps the pond from clumping.
for (int j = 0; j < fishes.size(); j++) {
if (j == i) {
continue;
}
Fish other = fishes.get(j);
if (other.hidden) {
continue;
}
float sx = f.x - other.x;
float sy = f.y - other.y;
float min = (f.size + other.size) * 1.45f;
float d2 = sx * sx + sy * sy;
if (d2 > 1f && d2 < min * min) {
float sdist = (float) Math.sqrt(d2);
float overlap = min - sdist;
// A little positional correction keeps big koi readable
// even when several of them crowd the same corner.
f.x += sx / sdist * overlap * 0.28f;
f.y += sy / sdist * overlap * 0.28f;
float push = (1f - sdist / min) * dp(420f);
f.vx += sx / sdist * push * dt;
f.vy += sy / sdist * push * dt;
}
}
float maxSpeed = f.baseSpeed * (1f + f.boost * 2.4f);
float sp = (float) Math.sqrt(f.vx * f.vx + f.vy * f.vy);
if (sp > maxSpeed && sp > 1f) {
f.vx = f.vx / sp * maxSpeed;
f.vy = f.vy / sp * maxSpeed;
}
f.x += f.vx * dt;
f.y += f.vy * dt;
f.angle = (float) Math.atan2(f.vy, f.vx);
f.phase += dt * (5f + sp / dp(42f));
float m = f.size * 2.2f + dp(10f);
if (f.x < m) {
f.x = m;
f.vx = Math.abs(f.vx);
f.targetX = f.size * 2.2f + dp(40f);
} else if (f.x > w - m) {
f.x = w - m;
f.vx = -Math.abs(f.vx);
f.targetX = w - f.size * 2.2f - dp(40f);
}
if (f.y < m) {
f.y = m;
f.vy = Math.abs(f.vy);
f.targetY = f.size * 2.2f + dp(40f);
} else if (f.y > h - m) {
f.y = h - m;
f.vy = -Math.abs(f.vy);
f.targetY = h - f.size * 2.2f - dp(40f);
}
}
for (int i = 0; i < bubbles.size(); i++) {
Bubble b = bubbles.get(i);
b.phase += dt * 0.7f;
b.y -= b.speed * dt;
b.x += (float) Math.sin(b.phase) * b.sway * dt;
if (b.y < -dp(20f)) {
b.y = h + dp(20f);
b.x = rnd.nextFloat() * w;
}
}
}
@Override
void touchDown(float x, float y, float t) {
touchGlowX = x;
touchGlowY = y;
touchGlowAge = 0f;
ripple(x, y, 0xCCBFF7F1, dp(52f));
ripple(x, y, 0x66FFFFFF, dp(30f));
for (int i = 0; i < 5; i++) {
float a = rnd.nextFloat() * 6.283f;
float sp = dp(20f + rnd.nextFloat() * 60f);
Particle p = particle(Particle.BUBBLE, x, y,
(float) Math.cos(a) * sp, (float) Math.sin(a) * sp,
0.5f + rnd.nextFloat() * 0.5f,
dp(2f + rnd.nextFloat() * 4f), 0xCCDFFFF9);
p.gravity = -dp(40f);
}
if (t - lastPlop > 0.10f) {
lastPlop = t;
sound.playPlop();
}
catchNear(x, y);
}
@Override
void touchMove(float x, float y, float t) {
touchGlowX = x;
touchGlowY = y;
touchGlowAge = 0f;
if (t - lastFxTime > 0.075f) {
lastFxTime = t;
ripple(x, y, 0x55BFF7F1, dp(30f));
for (int i = 0; i < 2; i++) {
float a = rnd.nextFloat() * 6.283f;
float sp = dp(10f + rnd.nextFloat() * 25f);
particle(Particle.BUBBLE, x, y,
(float) Math.cos(a) * sp, (float) Math.sin(a) * sp,
0.35f + rnd.nextFloat() * 0.4f,
dp(1.5f + rnd.nextFloat() * 2.5f), 0x88DFFFF9);
}
}
}
private void catchNear(float x, float y) {
for (int i = 0; i < fishes.size(); i++) {
Fish f = fishes.get(i);
if (f.hidden) {
continue;
}
float dx = f.x - x;
float dy = f.y - y;
float r = f.size * 1.9f;
if (dx * dx + dy * dy < r * r) {
f.hidden = true;
f.respawn = 1.1f + rnd.nextFloat() * 0.7f;
sparkle(f.x, f.y, f.color, 16, dp(260f));
sparkle(f.x, f.y, 0xFFFFF0C7, 8, dp(170f));
ripple(f.x, f.y, 0x99FFFFFF, dp(46f));
for (int b = 0; b < 9; b++) {
float a = rnd.nextFloat() * 6.283f;
float sp = dp(30f + rnd.nextFloat() * 80f);
Particle p = particle(Particle.BUBBLE, f.x, f.y,
(float) Math.cos(a) * sp, (float) Math.sin(a) * sp,
0.6f + rnd.nextFloat() * 0.7f,
dp(2f + rnd.nextFloat() * 4f), 0xCCFFFFFF);
p.gravity = -dp(55f);
}
sound.playChime();
return;
}
}
}
@Override
void draw(Canvas c, float t) {
bg.setDither(true);
bg.setShader(water);
c.drawRect(0, 0, w, h, bg);
drawCaustics(c, t);
drawBubbles(c);
drawSpecks(c);
for (int i = 0; i < fishes.size(); i++) {
Fish f = fishes.get(i);
if (!f.hidden) {
drawFish(c, f, t);
}
}
drawFx(c, paint);
// Soft light following the most recent touch.
if (touchGlowAge < 0.5f) {
float a = (1f - touchGlowAge / 0.5f) * 0.13f;
glow.setShader(new RadialGradient(touchGlowX, touchGlowY, dp(130f),
new int[]{withAlpha(0xFFFFFFFF, a), 0x00FFFFFF},
new float[]{0f, 1f}, Shader.TileMode.CLAMP));
c.drawCircle(touchGlowX, touchGlowY, dp(130f), glow);
glow.setShader(null);
}
paint.setShader(vignette);
c.drawRect(0, 0, w, h, paint);
paint.setShader(null);
}
private void drawCaustics(Canvas c, float t) {
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND);
stroke.setStrokeWidth(dp(15f));
int lines = 4;
for (int i = 0; i < lines; i++) {
float baseY = h * (0.13f + i * 0.20f);
wave.reset();
wave.moveTo(-dp(20f), baseY);
int steps = 16;
for (int s = 1; s <= steps; s++) {
float x = w * s / (float) steps;
float y = baseY
+ (float) Math.sin(x * 0.0048f + t * 0.55f + i * 1.7f) * dp(16f)
+ (float) Math.sin(x * 0.012f - t * 0.38f + i) * dp(5f);
wave.lineTo(x, y);
}
stroke.setColor((0x0D + i * 0x02) << 24 | 0x00E9FFFB);
c.drawPath(wave, stroke);
}
}
private void drawSpecks(Canvas c) {
paint.setStyle(Paint.Style.FILL);
for (int i = 0; i < speckX.length; i++) {
paint.setColor(i % 5 == 0 ? 0x18DFFFF9 : 0x0A9FE6E4);
c.drawCircle(speckX[i], speckY[i], speckR[i], paint);
}
}
private void drawBubbles(Canvas c) {
paint.setStyle(Paint.Style.FILL);
for (int i = 0; i < bubbles.size(); i++) {
Bubble b = bubbles.get(i);
paint.setColor((int) (b.alpha * 255) << 24 | 0x00DFFFF9);
c.drawCircle(b.x, b.y, b.r, paint);
paint.setColor((int) (b.alpha * 0.55f * 255) << 24 | 0x00FFFFFF);
c.drawCircle(b.x - b.r * 0.3f, b.y - b.r * 0.3f, b.r * 0.35f, paint);
}
}
private void drawFish(Canvas c, Fish f, float t) {
float L = f.size * 2.05f;
float H = f.size * 0.95f;
float wag = (float) Math.sin(f.phase) * 0.42f;
c.save();
c.translate(f.x, f.y);
c.rotate((float) Math.toDegrees(f.angle));
if (Math.cos(f.angle) < 0f) {
c.scale(1f, -1f);
}
// Tail, behind the body.
f.tail.reset();
f.tail.moveTo(-L * 0.70f, 0f);
f.tail.cubicTo(-L * 0.90f, -H * 0.55f + wag * H,
-L * 1.30f, -H * 0.80f + wag * H,
-L * 1.12f, 0f);
f.tail.cubicTo(-L * 1.30f, H * 0.80f + wag * H,
-L * 0.90f, H * 0.55f + wag * H,
-L * 0.70f, 0f);
f.tail.close();
paint.setStyle(Paint.Style.FILL);
paint.setColor(shade(f.color, 0.72f));
c.drawPath(f.tail, paint);
// Dorsal fin, just behind the body.
f.dorsal.reset();
f.dorsal.moveTo(-L * 0.04f, -H * 0.72f);
f.dorsal.quadTo(L * 0.15f, -H * 1.28f, L * 0.40f, -H * 0.50f);
f.dorsal.close();
paint.setColor(shade(f.color, 0.88f));
c.drawPath(f.dorsal, paint);
// Pectoral fin.
f.fin.reset();
f.fin.moveTo(L * 0.18f, H * 0.10f);
f.fin.quadTo(L * 0.02f, H * 0.78f, -L * 0.18f, H * 0.32f);
f.fin.close();
paint.setColor(shade(f.color, 0.86f));
c.drawPath(f.fin, paint);
// Body.
f.body.reset();
f.body.moveTo(L * 0.94f, 0f);
f.body.cubicTo(L * 0.60f, -H * 0.96f, -L * 0.40f, -H,
-L * 0.80f, 0f);
f.body.cubicTo(-L * 0.40f, H, L * 0.60f, H * 0.96f, L * 0.94f, 0f);
f.body.close();
paint.setColor(f.color);
c.drawPath(f.body, paint);
// Koi patches and a pale belly.
c.save();
c.clipPath(f.body);
if (f.pattern == 1) {
paint.setColor(0xCCFFF6E7);
c.drawCircle(-L * 0.18f, -H * 0.24f, H * 0.55f, paint);
c.drawCircle(L * 0.34f, H * 0.30f, H * 0.42f, paint);
paint.setColor(0x55FFFFFF);
c.drawCircle(-L * 0.55f, H * 0.14f, H * 0.38f, paint);
}
paint.setColor(0x33FFF8EA);
c.drawOval(-L * 0.62f, H * 0.15f, L * 0.65f, H * 1.15f, paint);
c.restore();
// Outline keeps every colour readable on the dark water.
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeWidth(dp(2.2f));
stroke.setColor(0x55201A19);
c.drawPath(f.body, stroke);
// Eye.
paint.setStyle(Paint.Style.FILL);
paint.setColor(0xFFFDFBF5);
c.drawCircle(L * 0.60f, -H * 0.12f, H * 0.15f, paint);
if (f.blink < 0f) {
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeWidth(dp(2f));
stroke.setColor(0xFF292326);
c.drawLine(L * 0.53f, -H * 0.12f, L * 0.67f, -H * 0.12f, stroke);
} else {
paint.setColor(0xFF292326);
c.drawCircle(L * 0.615f, -H * 0.12f, H * 0.072f, paint);
paint.setColor(0xFFFFFFFF);
c.drawCircle(L * 0.635f, -H * 0.15f, H * 0.026f, paint);
}
// Tiny highlight on the back.
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(dp(2f));
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setColor(0x55FFFFFF);
c.drawLine(L * 0.08f, -H * 0.52f, L * 0.42f, -H * 0.44f, paint);
c.restore();
}
@Override
void drawModeIcon(Canvas c, float cx, float cy, float size) {
c.save();
c.translate(cx, cy);
c.rotate(-8f);
float L = size * 1.1f;
float H = size * 0.36f;
paint.setStyle(Paint.Style.FILL);
paint.setColor(0xFFFFF7EA);
Path p = new Path();
p.moveTo(L * 0.48f, 0f);
p.cubicTo(L * 0.18f, -H, -L * 0.55f, -H, -L * 0.62f, 0f);
p.cubicTo(-L * 0.55f, H, L * 0.18f, H, L * 0.48f, 0f);
p.close();
Path tail = new Path();
tail.moveTo(-L * 0.55f, 0f);
tail.lineTo(-L * 0.95f, -H * 0.9f);
tail.lineTo(-L * 0.88f, 0f);
tail.lineTo(-L * 0.95f, H * 0.9f);
tail.close();
c.drawPath(tail, paint);
c.drawPath(p, paint);
paint.setColor(0xFF0E7C86);
c.drawCircle(L * 0.30f, -H * 0.10f, Math.max(1f, size * 0.075f), paint);
c.restore();
}
private static int shade(int color, float factor) {
int r = (int) (Color.red(color) * factor);
int g = (int) (Color.green(color) * factor);
int b = (int) (Color.blue(color) * factor);
return 0xFF000000 | (r << 16) | (g << 8) | b;
}
private static final class Fish {
float x;
float y;
float vx;
float vy;
float angle;
float size;
float phase;
float baseSpeed;
float targetX;
float targetY;
float retarget;
float boost;
float blink;
float respawn;
boolean hidden;
int color;
int pattern;
final Path body = new Path();
final Path tail = new Path();
final Path fin = new Path();
final Path dorsal = new Path();
}
private static final class Bubble {
float x;
float y;
float r;
float speed;
float sway;
float phase;
float alpha;
}
}
+370
View File
@@ -0,0 +1,370 @@
package com.local.pawplay;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RadialGradient;
import android.graphics.Shader;
import java.util.ArrayList;
/**
* The classic red dot: organic pauses, sudden darts, and an instant jump when
* a paw lands on it. No score, no failure, just pursuit.
*/
final class LaserScene extends Scene {
private final Paint bg = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final ArrayList<Trail> trail = new ArrayList<>();
private final ArrayList<Mote> motes = new ArrayList<>();
private Shader backdrop;
private Shader vignette;
private Shader dotGlow;
private Shader tightGlow;
private float x;
private float y;
private float vx;
private float vy;
private float tx;
private float ty;
private float pause;
private float pulse;
private float tempo = 1f;
private float retarget;
private float lastPing = -10f;
LaserScene(Context context, SoundKit sound) {
super(context, sound);
}
@Override
int labelRes() {
return R.string.mode_laser;
}
@Override
int accent() {
return 0xFFFF5A4E;
}
@Override
boolean isLight() {
return false;
}
@Override
void onResize() {
backdrop = new RadialGradient(w * 0.54f, h * 0.42f, Math.max(w, h) * 0.82f,
new int[]{0xFF2A2033, 0xFF17111F, 0xFF08060D},
new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP);
vignette = new RadialGradient(w * 0.5f, h * 0.5f, Math.max(w, h) * 0.78f,
new int[]{0x00000000, 0x00000000, 0x99000000},
new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP);
dotGlow = new RadialGradient(0, 0, dp(150f),
new int[]{0xAAFF3A2E, 0x55FF2A20, 0x00FF1A10},
new float[]{0f, 0.28f, 1f}, Shader.TileMode.CLAMP);
tightGlow = new RadialGradient(0, 0, dp(44f),
new int[]{0xFFFFB9AE, 0x99FF4A3C, 0x00FF2A20},
new float[]{0f, 0.45f, 1f}, Shader.TileMode.CLAMP);
x = w * 0.5f;
y = h * 0.5f;
vx = 0f;
vy = 0f;
chooseTarget(true);
trail.clear();
retarget = 1.8f;
pause = 0f;
pulse = 0f;
motes.clear();
int count = 26;
for (int i = 0; i < count; i++) {
Mote m = new Mote();
m.x = rnd.nextFloat() * w;
m.y = rnd.nextFloat() * h;
m.r = dp(0.8f + rnd.nextFloat() * 2.2f);
m.vx = (rnd.nextFloat() - 0.5f) * dp(7f);
m.vy = -dp(3f + rnd.nextFloat() * 8f);
m.phase = rnd.nextFloat() * 6.283f;
m.alpha = 0.05f + rnd.nextFloat() * 0.13f;
motes.add(m);
}
}
private void chooseTarget(boolean initial) {
float mx = dp(110f);
float my = dp(110f);
float nx = mx + rnd.nextFloat() * Math.max(1f, w - mx * 2f);
float ny = my + rnd.nextFloat() * Math.max(1f, h - my * 2f);
if (!initial) {
int guard = 0;
while (guard++ < 8) {
float dx = nx - x;
float dy = ny - y;
if (dx * dx + dy * dy > dp(300f) * dp(300f)) {
break;
}
nx = mx + rnd.nextFloat() * Math.max(1f, w - mx * 2f);
ny = my + rnd.nextFloat() * Math.max(1f, h - my * 2f);
}
tempo = 0.75f + rnd.nextFloat() * 0.7f;
}
tx = nx;
ty = ny;
}
@Override
void update(float dt, float t) {
updateFx(dt);
pulse = Math.max(0f, pulse - dt * 1.8f);
for (int i = 0; i < motes.size(); i++) {
Mote m = motes.get(i);
m.phase += dt * 0.6f;
m.x += (m.vx + (float) Math.sin(m.phase) * dp(4f)) * dt;
m.y += m.vy * dt;
if (m.y < -dp(10f)) {
m.y = h + dp(10f);
m.x = rnd.nextFloat() * w;
}
if (m.x < -dp(10f)) {
m.x = w + dp(10f);
} else if (m.x > w + dp(10f)) {
m.x = -dp(10f);
}
}
// Keep a short, lazy trail.
for (int i = trail.size() - 1; i >= 0; i--) {
Trail p = trail.get(i);
p.age += dt;
if (p.age > 0.34f) {
trail.remove(i);
}
}
if (trail.isEmpty()
|| Math.abs(trail.get(trail.size() - 1).x - x) > dp(3f)
|| Math.abs(trail.get(trail.size() - 1).y - y) > dp(3f)) {
Trail p = new Trail();
p.x = x;
p.y = y;
p.age = 0f;
trail.add(p);
if (trail.size() > 26) {
trail.remove(0);
}
}
if (pause > 0f) {
pause -= dt;
vx *= 0.82f;
vy *= 0.82f;
return;
}
float dx = tx - x;
float dy = ty - y;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
float maxSpeed = dp(760f) * tempo;
// A little perpendicular wobble makes the dot feel hand-driven.
float wobble = (float) Math.sin(t * 3.4f) * dp(34f);
float nx = dx == 0f && dy == 0f ? 0f : dx / Math.max(1f, dist);
float ny = dx == 0f && dy == 0f ? 0f : dy / Math.max(1f, dist);
float desiredVx = dx * 3.6f - ny * wobble * 1.7f;
float desiredVy = dy * 3.6f + nx * wobble * 1.7f;
float ds = (float) Math.sqrt(desiredVx * desiredVx + desiredVy * desiredVy);
if (ds > maxSpeed) {
desiredVx = desiredVx / ds * maxSpeed;
desiredVy = desiredVy / ds * maxSpeed;
}
vx += (desiredVx - vx) * Math.min(1f, dt * 6.5f);
vy += (desiredVy - vy) * Math.min(1f, dt * 6.5f);
x += vx * dt;
y += vy * dt;
retarget -= dt;
if (dist < dp(22f) || retarget <= 0f) {
x = Math.max(dp(80f), Math.min(w - dp(80f), x));
y = Math.max(dp(80f), Math.min(h - dp(80f), y));
chooseTarget(false);
retarget = 1.2f + rnd.nextFloat() * 2.2f;
pause = 0.20f + rnd.nextFloat() * 0.55f;
}
}
@Override
void touchDown(float x, float y, float t) {
float dx = x - this.x;
float dy = y - this.y;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
ripple(x, y, 0x33FFD9D0, dp(34f));
if (dist < dp(270f)) {
pulse = 1f;
if (dist < dp(90f)) {
// A direct hit teleports the dot, just like flicking a laser pointer.
ripple(this.x, this.y, 0x99FF8A78, dp(58f));
sparkle(this.x, this.y, 0xFFFFC9BE, 14, dp(330f));
chooseTarget(false);
this.x = tx;
this.y = ty;
vx = 0f;
vy = 0f;
pause = 0.04f;
retarget = 0.9f + rnd.nextFloat() * 1.2f;
ripple(this.x, this.y, 0x77FF6F5C, dp(32f));
if (t - lastPing > 0.08f) {
lastPing = t;
sound.playChime();
}
} else {
chooseTarget(false);
sparkle(this.x, this.y, 0xFFFFB4A6, 8, dp(190f));
vx += dx / Math.max(1f, dist) * dp(600f);
vy += dy / Math.max(1f, dist) * dp(600f);
if (t - lastPing > 0.09f) {
lastPing = t;
sound.playPing();
}
}
} else {
if (t - lastPing > 0.12f) {
lastPing = t;
sound.playPing();
}
}
}
@Override
void touchMove(float x, float y, float t) {
float dx = x - this.x;
float dy = y - this.y;
if (dx * dx + dy * dy < dp(160f) * dp(160f)) {
sparkle(this.x, this.y, 0x55FFB4A6, 1, dp(110f));
}
}
@Override
void draw(Canvas c, float t) {
bg.setShader(backdrop);
c.drawRect(0, 0, w, h, bg);
drawRug(c, t);
drawMotes(c);
drawTrail(c);
drawDot(c, t);
drawFx(c, paint);
paint.setShader(vignette);
c.drawRect(0, 0, w, h, paint);
paint.setShader(null);
}
private void drawRug(Canvas c, float t) {
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(dp(1.2f));
paint.setColor(0x12FFE4C8);
float spacing = dp(58f);
float shift = (t * dp(2f)) % spacing;
for (float x0 = -h; x0 < w + h; x0 += spacing) {
c.drawLine(x0 + shift, 0, x0 + shift - h, h, paint);
}
paint.setColor(0x0AFFE4C8);
for (float y0 = -w; y0 < h + w; y0 += spacing) {
c.drawLine(0, y0 - shift, w, y0 - shift + w, paint);
}
}
private void drawMotes(Canvas c) {
paint.setStyle(Paint.Style.FILL);
for (int i = 0; i < motes.size(); i++) {
Mote m = motes.get(i);
paint.setColor(((int) (m.alpha * 255) << 24) | 0x00FFE9D2);
c.drawCircle(m.x, m.y, m.r, paint);
}
}
private void drawTrail(Canvas c) {
paint.setStyle(Paint.Style.FILL);
for (int i = 0; i < trail.size(); i++) {
Trail p = trail.get(i);
float k = 1f - p.age / 0.34f;
if (k <= 0f) {
continue;
}
float r = dp(26f) * k;
paint.setColor(((int) (0.10f * k * 255) << 24) | 0x00FF5545);
c.drawCircle(p.x, p.y, r, paint);
}
}
private void drawDot(Canvas c, float t) {
c.save();
c.translate(x, y);
float breathe = 1f + (float) Math.sin(t * 7.2f) * 0.05f + pulse * 0.22f;
c.save();
c.scale(breathe, breathe);
paint.setShader(dotGlow);
c.drawCircle(0f, 0f, dp(150f), paint);
paint.setShader(tightGlow);
c.drawCircle(0f, 0f, dp(44f), paint);
paint.setShader(null);
c.restore();
paint.setStyle(Paint.Style.FILL);
paint.setColor(0xFFFF352A);
c.drawCircle(0f, 0f, dp(17f), paint);
paint.setColor(0xFFFFD7CE);
c.drawCircle(0f, 0f, dp(10f), paint);
paint.setColor(0xFFFFFFFF);
c.drawCircle(-dp(2.5f), -dp(2.5f), dp(4.6f), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(dp(1.8f));
paint.setColor(0xAAFF8F7E);
c.drawCircle(0f, 0f, dp(25f), paint);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setColor(0x88FF6A58);
float ray = dp(40f) + pulse * dp(10f);
c.drawLine(-ray, 0f, -dp(30f), 0f, paint);
c.drawLine(ray, 0f, dp(30f), 0f, paint);
c.drawLine(0f, -ray, 0f, -dp(30f), paint);
c.drawLine(0f, ray, 0f, dp(30f), paint);
c.restore();
}
@Override
void drawModeIcon(Canvas c, float cx, float cy, float size) {
paint.setStyle(Paint.Style.FILL);
paint.setColor(0x33FFF7EA);
c.drawCircle(cx, cy, size * 0.44f, paint);
paint.setColor(0xFFFFF7EA);
c.drawCircle(cx, cy, size * 0.13f, paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(size * 0.045f);
paint.setColor(0xCCFFF7EA);
c.drawCircle(cx, cy, size * 0.28f, paint);
}
private static final class Trail {
float x;
float y;
float age;
}
private static final class Mote {
float x;
float y;
float r;
float vx;
float vy;
float phase;
float alpha;
}
}
+133
View File
@@ -0,0 +1,133 @@
package com.local.pawplay;
import android.app.Activity;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.view.DisplayCutout;
import android.view.View;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
/** Immersive, single-activity shell. No permissions, no network, no ads. */
public final class MainActivity extends Activity {
private CatPlayView game;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().setStatusBarColor(Color.TRANSPARENT);
getWindow().setNavigationBarColor(Color.TRANSPARENT);
if (Build.VERSION.SDK_INT >= 29) {
getWindow().setNavigationBarContrastEnforced(false);
getWindow().setStatusBarContrastEnforced(false);
}
if (Build.VERSION.SDK_INT >= 28) {
WindowManager.LayoutParams attrs = getWindow().getAttributes();
attrs.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(attrs);
}
if (Build.VERSION.SDK_INT >= 30) {
getWindow().setDecorFitsSystemWindows(false);
}
game = new CatPlayView(this);
setContentView(game);
game.requestFocus();
View decor = getWindow().getDecorView();
decor.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
int l = 0;
int t = 0;
int r = 0;
int b = 0;
if (Build.VERSION.SDK_INT >= 28) {
DisplayCutout cutout = insets.getDisplayCutout();
if (cutout != null) {
l = cutout.getSafeInsetLeft();
t = cutout.getSafeInsetTop();
r = cutout.getSafeInsetRight();
b = cutout.getSafeInsetBottom();
}
}
game.setSafeInsets(l, t, r, b);
return insets;
}
});
hideSystemUi();
}
private void hideSystemUi() {
if (Build.VERSION.SDK_INT >= 30) {
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
controller.hide(WindowInsets.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
hideSystemUi();
}
}
@Override
protected void onResume() {
super.onResume();
hideSystemUi();
if (game != null) {
game.resumeGame();
}
}
@Override
protected void onPause() {
if (game != null) {
game.pauseGame();
}
super.onPause();
}
@Override
protected void onDestroy() {
if (game != null) {
game.releaseGame();
}
super.onDestroy();
}
@Override
public void onConfigurationChanged(android.content.res.Configuration newConfig) {
super.onConfigurationChanged(newConfig);
hideSystemUi();
if (game != null) {
game.invalidate();
}
}
@Override
public void onBackPressed() {
if (game == null || !game.handleBack()) {
super.onBackPressed();
}
}
}
+110
View File
@@ -0,0 +1,110 @@
package com.local.pawplay;
import android.graphics.Canvas;
import android.graphics.Paint;
/** A tiny pooled-ish effect sprite. Kept allocation free while drawing. */
final class Particle {
static final int DOT = 0;
static final int STAR = 1;
static final int BUBBLE = 2;
static final int RING = 3;
static final int DOWN = 4;
float x;
float y;
float vx;
float vy;
float life;
float maxLife;
float size;
float rot;
float spin;
float gravity;
float drag;
int color;
int kind;
boolean update(float dt) {
life -= dt;
if (life <= 0f) {
return false;
}
vy += gravity * dt;
float d = 1f - drag * dt;
if (d < 0f) {
d = 0f;
}
vx *= d;
vy *= d;
x += vx * dt;
y += vy * dt;
rot += spin * dt;
return true;
}
void draw(Canvas c, Paint p) {
float k = life / maxLife;
if (k < 0f) {
k = 0f;
}
int a = (int) (255 * (kind == RING ? k * 0.75f : k));
if (a <= 3) {
return;
}
int col = (color & 0x00FFFFFF) | (a << 24);
switch (kind) {
case STAR: {
p.setStyle(Paint.Style.STROKE);
p.setStrokeWidth(Math.max(1f, size * 0.16f));
p.setStrokeCap(Paint.Cap.ROUND);
p.setColor(col);
float r = size * (0.5f + 0.5f * k);
float dx = (float) Math.cos(rot);
float dy = (float) Math.sin(rot);
c.drawLine(x - dx * r, y - dy * r, x + dx * r, y + dy * r, p);
c.drawLine(x - dy * r, y + dx * r, x + dy * r, y - dx * r, p);
p.setStyle(Paint.Style.FILL);
p.setColor((0xFFFFFFFF & 0x00FFFFFF) | (a << 24));
c.drawCircle(x, y, Math.max(1f, size * 0.14f), p);
break;
}
case BUBBLE: {
p.setStyle(Paint.Style.STROKE);
p.setStrokeWidth(Math.max(1f, size * 0.09f));
p.setColor(col);
c.drawCircle(x, y, size, p);
p.setStyle(Paint.Style.FILL);
p.setColor((0xFFFFFFFF & 0x00FFFFFF) | ((int) (a * 0.55f) << 24));
c.drawCircle(x - size * 0.32f, y - size * 0.34f, Math.max(1f, size * 0.18f), p);
break;
}
case RING: {
float progress = 1f - k;
float radius = size * (0.25f + progress * 1.55f);
p.setStyle(Paint.Style.STROKE);
p.setStrokeWidth(Math.max(1f, size * 0.08f * (1f - progress * 0.55f)));
p.setColor(col);
c.drawCircle(x, y, radius, p);
break;
}
case DOWN: {
p.setStyle(Paint.Style.STROKE);
p.setStrokeWidth(Math.max(1f, size * 0.12f));
p.setStrokeCap(Paint.Cap.ROUND);
p.setColor(col);
float dx = (float) Math.cos(rot) * size;
float dy = (float) Math.sin(rot) * size;
c.drawLine(x - dx, y - dy, x + dx, y + dy, p);
break;
}
default: {
p.setStyle(Paint.Style.FILL);
p.setColor(col);
c.drawCircle(x, y, size, p);
break;
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package com.local.pawplay;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RadialGradient;
import android.graphics.Shader;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Random;
/** Base class for the three cat toys. */
abstract class Scene {
protected final Context context;
protected final SoundKit sound;
protected final Random rnd = new Random();
protected final ArrayList<Particle> fx = new ArrayList<>();
protected int w;
protected int h;
protected float d;
Scene(Context context, SoundKit sound) {
this.context = context;
this.sound = sound;
}
final void resize(int width, int height, float density) {
if (width <= 0 || height <= 0) {
return;
}
w = width;
h = height;
d = density;
onResize();
}
abstract int labelRes();
abstract int accent();
abstract boolean isLight();
abstract void onResize();
abstract void update(float dt, float t);
abstract void draw(Canvas c, float t);
void touchDown(float x, float y, float t) {
}
void touchMove(float x, float y, float t) {
}
abstract void drawModeIcon(Canvas c, float cx, float cy, float size);
int hudInk() {
return isLight() ? 0xFF2B2430 : 0xFFFFF7EA;
}
int hudBackdrop() {
return isLight() ? 0x33FFFFFF : 0x26000000;
}
protected float dp(float value) {
return value * d;
}
protected int withAlpha(int color, float alpha) {
int a = (int) (255 * Math.max(0f, Math.min(1f, alpha)));
return (color & 0x00FFFFFF) | (a << 24);
}
protected void addFx(Particle p) {
if (fx.size() > 300) {
fx.remove(0);
}
fx.add(p);
}
protected Particle particle(int kind, float x, float y, float vx, float vy,
float life, float size, int color) {
Particle p = new Particle();
p.kind = kind;
p.x = x;
p.y = y;
p.vx = vx;
p.vy = vy;
p.life = life;
p.maxLife = life;
p.size = size;
p.color = color;
p.rot = rnd.nextFloat() * 6.28318f;
p.spin = (rnd.nextFloat() - 0.5f) * 5f;
p.drag = 0.4f;
accentParticle(p);
addFx(p);
return p;
}
protected void accentParticle(Particle p) {
}
protected void ripple(float x, float y, int color, float size) {
Particle p = new Particle();
p.kind = Particle.RING;
p.x = x;
p.y = y;
p.life = 0.75f;
p.maxLife = 0.75f;
p.size = size;
p.color = color;
p.drag = 0f;
addFx(p);
}
protected void sparkle(float x, float y, int color, int count, float power) {
for (int i = 0; i < count; i++) {
double a = rnd.nextDouble() * 6.28318;
float speed = power * (0.5f + rnd.nextFloat());
Particle p = particle(Particle.STAR, x, y,
(float) Math.cos(a) * speed, (float) Math.sin(a) * speed,
0.45f + rnd.nextFloat() * 0.55f,
dp(3f + rnd.nextFloat() * 4f), color);
p.gravity = dp(90f);
p.drag = 1.2f;
}
}
protected void updateFx(float dt) {
for (Iterator<Particle> it = fx.iterator(); it.hasNext(); ) {
if (!it.next().update(dt)) {
it.remove();
}
}
}
protected void drawFx(Canvas c, Paint p) {
for (int i = 0; i < fx.size(); i++) {
fx.get(i).draw(c, p);
}
}
protected Shader radial(float cx, float cy, float radius, int inner, int outer) {
return new RadialGradient(cx, cy, Math.max(1f, radius), inner, outer,
Shader.TileMode.CLAMP);
}
}
+94
View File
@@ -0,0 +1,94 @@
package com.local.pawplay;
import android.content.Context;
import android.media.AudioAttributes;
import android.media.SoundPool;
import java.util.Random;
/** Four short, soft sounds generated offline in tools/generate_sounds.py. */
final class SoundKit {
private final Random rnd = new Random();
private SoundPool pool;
private int plop;
private int chime;
private int whoosh;
private int ping;
private boolean enabled = true;
SoundKit(Context context) {
try {
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build();
pool = new SoundPool.Builder()
.setMaxStreams(6)
.setAudioAttributes(attrs)
.build();
plop = pool.load(context, R.raw.plop, 1);
chime = pool.load(context, R.raw.chime, 1);
whoosh = pool.load(context, R.raw.whoosh, 1);
ping = pool.load(context, R.raw.ping, 1);
} catch (Throwable ignored) {
pool = null;
}
}
void setEnabled(boolean on) {
enabled = on;
if (!on) {
stopAll();
}
}
boolean isEnabled() {
return enabled;
}
void playPlop() {
play(plop, 0.55f, 0.95f, 0.08f);
}
void playChime() {
play(chime, 0.32f, 0.97f, 0.05f);
}
void playWhoosh() {
play(whoosh, 0.28f, 0.92f, 0.12f);
}
void playPing() {
play(ping, 0.30f, 0.96f, 0.09f);
}
private void play(int id, float volume, float rateBase, float rateSpread) {
if (!enabled || pool == null || id == 0) {
return;
}
float rate = rateBase + rnd.nextFloat() * rateSpread;
try {
pool.play(id, volume, volume, 1, 0, rate);
} catch (Throwable ignored) {
}
}
void stopAll() {
if (pool != null) {
try {
pool.autoPause();
} catch (Throwable ignored) {
}
}
}
void release() {
if (pool != null) {
try {
pool.release();
} catch (Throwable ignored) {
}
pool = null;
}
}
}