Files
pihkaal-me/app/components/Settings/BottomScreen/Menus/TouchScreen/TapTap.vue

464 lines
11 KiB
Vue

<script setup lang="ts">
import { useLocalStorage } from "@vueuse/core";
import gsap from "gsap";
const app = useAppStore();
const store = useSettingsStore();
const achievements = useAchievementsStore();
const confirmationModal = useConfirmationModal();
const { assets } = useAssets();
const { onRender, onClick } = useScreen();
const BAR_HEIGHT = 24;
const MAX_RADIUS = 27;
const MIN_RADIUS = 3;
const BASE_SHRINK_SPEED = 0.22;
const MAX_SHRINK_SPEED = 0.5;
const BASE_SPAWN_INTERVAL = 45;
const MIN_SPAWN_INTERVAL = 10;
const DIFFICULTY_SCORE_CAP = 100;
const RING_STROKE_WIDTH = 5;
const RING_EXPAND_SPEED = 0.6;
const RING_FADE_SPEED = 0.1;
const MAX_LIVES = 3;
const CROSSHAIR_MOVE_SPEED = 10;
const FRAME_TIME = 1000 / 60;
const getDifficulty = () => {
const progress = Math.min(score / DIFFICULTY_SCORE_CAP, 1);
return {
shrinkSpeed:
BASE_SHRINK_SPEED + (MAX_SHRINK_SPEED - BASE_SHRINK_SPEED) * progress,
spawnInterval: Math.floor(
BASE_SPAWN_INTERVAL -
(BASE_SPAWN_INTERVAL - MIN_SPAWN_INTERVAL) * progress,
),
};
};
type Circle = {
x: number;
y: number;
radius: number;
};
type Ring = {
x: number;
y: number;
radius: number;
alpha: number;
};
const state = ref<"waiting" | "playing" | "paused" | "ended">("waiting");
let lives = MAX_LIVES;
let circles: Circle[] = [];
let rings: Ring[] = [];
let spawnTimer = 0;
// crosshair
let x = 0;
let y = LOGICAL_HEIGHT * 2 - 20;
let targetX = 31;
let targetY = 31;
let horizontalFirst = false;
const highScore = useLocalStorage("nds-taptap-high-score", 0);
let score = 0;
let isNewBest = false;
const isAnimating = ref(true);
const labelsReady = ref(false);
const bLabel = $t("common.quit");
const aLabel = computed(() => {
if (!labelsReady.value) return $t("common.select");
return state.value === "waiting" ? $t("common.start") : $t("common.restart");
});
const buttonsRef = useTemplateRef<{ forceAnimateBLabel: () => void }>(
"buttons",
);
const AREA_FADE_DURATION = 0.2;
const SCORE_OFFSET = -20;
const SCORE_DURATION = 0.167;
const animation = reactive({
areaOpacity: 0,
circlesOpacity: 1,
scoreOffsetY: SCORE_OFFSET,
});
const animateIntro = async () => {
isAnimating.value = true;
await gsap
.timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to(
animation,
{ areaOpacity: 1, duration: AREA_FADE_DURATION, ease: "none" },
0,
)
.to(
animation,
{ scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" },
AREA_FADE_DURATION,
)
.call(
() => {
labelsReady.value = true;
buttonsRef.value?.forceAnimateBLabel();
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
);
};
const animateOutro = async () => {
isAnimating.value = true;
targetX = 0;
targetY = LOGICAL_HEIGHT;
await gsap
.timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to(
animation,
{ areaOpacity: 0, duration: AREA_FADE_DURATION, ease: "none" },
0,
)
.to(
animation,
{ circlesOpacity: 0, duration: AREA_FADE_DURATION, ease: "none" },
0,
)
.to(
animation,
{ scoreOffsetY: SCORE_OFFSET, duration: SCORE_DURATION, ease: "none" },
0,
);
};
onMounted(() => {
animateIntro();
});
const handleActivateB = () => {
if (isAnimating.value) return;
if (state.value === "playing") {
state.value = "paused";
confirmationModal.open({
text: $t("settings.touchScreen.tapTap.quitConfirmation"),
bLabel: $t("common.no"),
aLabel: $t("common.yes"),
onClosed: async (choice) => {
if (choice === "A") {
await animateOutro();
store.closeSubMenu(true);
} else {
state.value = "playing";
}
},
keepButtonsDown: (choice) => choice === "A",
});
} else {
animateOutro().then(() => store.closeSubMenu());
}
};
const handleActivateA = async () => {
if (isAnimating.value) return;
if (state.value === "playing") {
state.value = "paused";
confirmationModal.open({
text: $t("settings.touchScreen.tapTap.restartConfirmation"),
bLabel: $t("common.no"),
aLabel: $t("common.yes"),
onClosed: (choice) => {
if (choice === "A") {
resetGame();
} else {
state.value = "playing";
}
},
});
} else if (state.value === "waiting") {
assets.audio.menuConfirmed.play();
await gsap.to(animation, {
areaOpacity: 0,
duration: AREA_FADE_DURATION,
ease: "none",
});
resetGame();
} else {
resetGame();
}
};
const moveTowards = (current: number, target: number, dt: number) => {
if (current === target) return current;
const direction = Math.sign(target - current);
return (
current +
direction * Math.min(CROSSHAIR_MOVE_SPEED * dt, Math.abs(target - current))
);
};
const spawnCircle = () => {
const PADDING_X = MAX_RADIUS;
const PADDING_TOP = MAX_RADIUS + BAR_HEIGHT;
const PADDING_BOT = MAX_RADIUS + BAR_HEIGHT;
const MAX_ATTEMPTS = 25;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const newX = Math.floor(
PADDING_X + Math.random() * (LOGICAL_WIDTH - PADDING_X * 2),
);
const newY = Math.floor(
PADDING_TOP +
Math.random() * (LOGICAL_HEIGHT - PADDING_TOP - PADDING_BOT),
);
const overlaps = circles.some((circle) => {
const dx = newX - circle.x;
const dy = newY - circle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < MAX_RADIUS + circle.radius;
});
if (!overlaps) {
circles.push({ x: newX, y: newY, radius: MAX_RADIUS });
return;
}
}
};
const resetGame = () => {
state.value = "playing";
circles = [];
rings = [];
spawnTimer = 0;
score = 0;
lives = MAX_LIVES;
isNewBest = false;
};
const showDeathScreen = () => {
const title = isNewBest
? $t("settings.touchScreen.tapTap.newRecord")
: $t("settings.touchScreen.tapTap.gameOver");
const text = `${title}\n${$t("settings.touchScreen.tapTap.finalScore", { score })}`;
confirmationModal.open({
text,
bLabel: $t("common.quit"),
aLabel: $t("common.restart"),
onClosed: async (choice) => {
if (choice === "A") {
resetGame();
} else {
await animateOutro();
store.closeSubMenu(true);
}
},
keepButtonsDown: (choice) => choice === "B",
});
};
onClick((mx, my) => {
if (state.value !== "playing") return;
if (my <= BAR_HEIGHT || my >= LOGICAL_HEIGHT - BAR_HEIGHT - 1) return;
targetX = mx;
targetY = my;
horizontalFirst = Math.abs(targetX - x) > Math.abs(targetY - y);
for (let i = circles.length - 1; i >= 0; i--) {
const circle = circles[i]!;
const dx = mx - circle.x;
const dy = my - circle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= circle.radius) {
assets.audio.type.play(0.35);
rings.push({
x: circle.x,
y: circle.y,
radius: circle.radius,
alpha: 1,
});
circles.splice(i, 1);
score++;
if (score > highScore.value) {
highScore.value = score;
isNewBest = true;
}
if (score === 20) achievements.unlock("taptap_score_20");
break;
}
}
});
onRender((ctx, deltaTime) => {
const dt = deltaTime / FRAME_TIME;
// update crosshair position in all modes except paused
if (state.value !== "paused") {
if (horizontalFirst) {
if (x !== targetX) x = moveTowards(x, targetX, dt);
else y = moveTowards(y, targetY, dt);
} else {
if (y !== targetY) y = moveTowards(y, targetY, dt);
else x = moveTowards(x, targetX, dt);
}
}
// game logic
if (state.value === "playing") {
const { shrinkSpeed, spawnInterval } = getDifficulty();
// spawn circles
spawnTimer += deltaTime;
if (spawnTimer >= spawnInterval * FRAME_TIME) {
spawnCircle();
spawnTimer = 0;
}
// update circles and rings
circles = circles.filter((circle) => {
circle.radius -= shrinkSpeed * dt;
if (circle.radius < MIN_RADIUS) {
lives--;
if (lives <= 0) {
state.value = "ended";
assets.audio.invalid.play();
showDeathScreen();
} else {
assets.audio.eraser.play();
}
return false;
}
return true;
});
rings = rings.filter((ring) => {
ring.radius += RING_EXPAND_SPEED * dt;
ring.alpha -= RING_FADE_SPEED * dt;
return ring.alpha > 0;
});
}
// draw start instructions
if (state.value === "waiting") {
ctx.save();
ctx.globalAlpha = animation.areaOpacity;
ctx.fillStyle = "#fbfbfb";
ctx.textBaseline = "top";
ctx.fillRect(32, 112, 191, 31);
ctx.fillStyle = "#797979";
ctx.font = "10px NDS10";
fillTextHCentered(
ctx,
$t("settings.touchScreen.tapTap.startPrompt"),
32,
123,
191,
);
ctx.restore();
}
if (state.value !== "waiting") {
// draw circles and rings
for (const circle of circles) {
ctx.globalAlpha = animation.circlesOpacity;
ctx.fillStyle = app.color.hex;
fillCirclePixelated(ctx, circle.x, circle.y, circle.radius);
ctx.fillStyle = "#fbfafa";
fillCirclePixelated(ctx, circle.x, circle.y, circle.radius * 0.6);
}
for (const ring of rings) {
ctx.globalAlpha = ring.alpha * animation.circlesOpacity;
ctx.fillStyle = app.color.hex;
strokeCirclePixelated(
ctx,
ring.x,
ring.y,
ring.radius,
RING_STROKE_WIDTH,
);
}
ctx.globalAlpha = 1;
}
// draw crosshair
const cx = Math.floor(x);
const cy = Math.floor(y);
ctx.fillStyle = "#494949";
ctx.fillRect(cx, 0, 1, LOGICAL_HEIGHT);
ctx.fillRect(0, cy, LOGICAL_WIDTH, 1);
ctx.fillStyle = "#fb0000";
ctx.fillRect(cx - 4, cy - 4, 9, 9);
ctx.fillStyle = "#fbfafa";
ctx.fillRect(cx - 2, cy - 2, 5, 5);
ctx.fillStyle = "#fb0000";
ctx.fillRect(cx, cy - 1, 1, 3);
ctx.fillRect(cx - 1, cy, 3, 1);
}, 0);
onRender((ctx) => {
ctx.translate(0, animation.scoreOffsetY);
drawButton(
ctx,
$t("settings.touchScreen.tapTap.score", { score }),
10,
2,
88,
true,
);
drawButton(
ctx,
$t("settings.touchScreen.tapTap.best", { best: highScore.value }),
108,
2,
88,
true,
);
drawButton(
ctx,
lives > 0 ? ICONS.HEART.repeat(lives) : ICONS.SAD,
206,
2,
40,
true,
);
}, 110);
defineOptions({ render: () => null });
</script>
<template>
<CommonButtons
ref="buttons"
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="bLabel"
:a-label="aLabel"
@activate-b="handleActivateB"
@activate-a="handleActivateA"
/>
</template>