Files
pihkaal-me/app/components/Settings/BottomScreen/Menus/User/Snake.vue

369 lines
8.1 KiB
Vue

<script setup lang="ts">
import * as THREE from "three";
import { useIntervalFn, useLocalStorage } from "@vueuse/core";
import gsap from "gsap";
const store = useSettingsStore();
const achievements = useAchievementsStore();
const confirmationModal = useConfirmationModal();
const { onRender } = useScreen();
const { assets: atlas } = useAssets();
const assets = atlas.images.settings.bottomScreen;
const highScore = useLocalStorage("snake_high_score", 0);
const BOARD_SLIDE_OFFSET = 96;
const BOARD_SLIDE_DURATION = 0.25;
const TEXT_FADE_DURATION = 0.15;
const SCORE_OFFSET = -20;
const SCORE_DURATION = 0.167;
const isAnimating = ref(true);
const state = ref<"waiting" | "alive" | "pause" | "dead">("waiting");
const labelsReady = ref(false);
const bLabel = computed(() =>
labelsReady.value ? $t("common.quit") : $t("common.goBack"),
);
const aLabel = computed(() => {
if (!labelsReady.value) return $t("common.select");
return state.value === "waiting" ? $t("common.start") : $t("common.restart");
});
const intro = reactive({
boardOffsetY: BOARD_SLIDE_OFFSET,
boardOpacity: 0,
textOpacity: 0,
scoreOffsetY: SCORE_OFFSET,
});
const animateIntro = async () => {
isAnimating.value = true;
await gsap
.timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to(
intro,
{ boardOffsetY: 0, duration: BOARD_SLIDE_DURATION, ease: "none" },
0,
)
.to(
intro,
{ boardOpacity: 1, duration: BOARD_SLIDE_DURATION, ease: "none" },
0,
)
.to(
intro,
{ textOpacity: 1, duration: TEXT_FADE_DURATION, ease: "none" },
BOARD_SLIDE_DURATION,
)
.to(
intro,
{ scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" },
BOARD_SLIDE_DURATION + TEXT_FADE_DURATION,
)
.call(
() => {
labelsReady.value = true;
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
);
};
const animateOutro = async () => {
isAnimating.value = true;
await gsap
.timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to(
intro,
{
boardOffsetY: BOARD_SLIDE_OFFSET,
duration: BOARD_SLIDE_DURATION,
ease: "none",
},
0,
)
.to(
intro,
{ boardOpacity: 0, duration: BOARD_SLIDE_DURATION, ease: "none" },
0,
)
.to(
intro,
{ scoreOffsetY: SCORE_OFFSET, duration: SCORE_DURATION, ease: "none" },
0,
);
};
onMounted(() => {
animateIntro();
});
const handleActivateB = async () => {
if (isAnimating.value) return;
switch (state.value) {
case "alive": {
state.value = "pause";
confirmationModal.open({
text: $t("settings.user.snake.quitConfirmation"),
bLabel: $t("common.no"),
aLabel: $t("common.yes"),
onClosed: async (choice) => {
if (choice === "A") {
await animateOutro();
store.closeSubMenu();
} else {
state.value = "alive";
}
},
keepButtonsDown: (choice) => choice === "A",
});
break;
}
case "dead":
case "waiting": {
await animateOutro();
store.closeSubMenu();
break;
}
}
};
const handleActivateA = () => {
if (isAnimating.value) return;
switch (state.value) {
case "alive": {
state.value = "pause";
confirmationModal.open({
text: $t("settings.user.snake.restartConfirmation"),
bLabel: $t("common.no"),
aLabel: $t("common.yes"),
onClosed: (choice) => {
if (choice === "A") {
spawn();
} else {
state.value = "alive";
}
},
});
break;
}
case "waiting": {
spawn();
break;
}
case "dead": {
spawn();
break;
}
}
};
const BOARD_X = 15;
const BOARD_Y = 33;
const BOARD_WIDTH = 13;
const BOARD_HEIGHT = 8;
const CELL_SIZE = 15;
const CELL_PADDING = 1;
const position = new THREE.Vector2();
const direction = new THREE.Vector2();
const nextDirection = new THREE.Vector2();
const tail: THREE.Vector2[] = [];
const food = new THREE.Vector2();
let score = 0;
const randomFoodPos = (): THREE.Vector2 => {
// can't spawn on the head or tail
const occupiedPositions = [position, ...tail];
const emptyPositions: THREE.Vector2[] = [];
for (let x = 0; x < BOARD_WIDTH; x++) {
for (let y = 0; y < BOARD_HEIGHT; y++) {
const candidate = new THREE.Vector2(x, y);
if (!occupiedPositions.find((part) => part.equals(candidate))) {
emptyPositions.push(candidate);
}
}
}
return emptyPositions[THREE.MathUtils.randInt(0, emptyPositions.length - 1)]!;
};
const eat = () => {
highScore.value = Math.max(highScore.value, score);
food.copy(randomFoodPos());
score += 1;
if (score === 40) {
achievements.unlock("snake_score_25");
}
};
const die = () => {
state.value = "dead";
};
const spawn = () => {
state.value = "alive";
score = 0;
tail.length = 0;
position.set(0, 0);
direction.set(1, 0);
nextDirection.set(1, 0);
food.copy(randomFoodPos());
};
useIntervalFn(() => {
if (state.value !== "alive") return;
direction.copy(nextDirection);
const previousPosition = position.clone();
position.set(
(position.x + direction.x + BOARD_WIDTH) % BOARD_WIDTH,
(position.y + direction.y + BOARD_HEIGHT) % BOARD_HEIGHT,
);
if (!position.equals(previousPosition)) {
if (tail.find((part) => part.equals(position))) {
die();
}
tail.push(previousPosition);
if (food.equals(position)) {
eat();
} else {
tail.shift();
}
}
}, 200);
const cellToBoardPos = (pos: THREE.Vector2) => ({
x: BOARD_X + CELL_SIZE + 1 + pos.x * (CELL_SIZE + CELL_PADDING),
y: BOARD_Y + pos.y * (CELL_SIZE + CELL_PADDING),
});
onRender((ctx) => {
assets.background.draw(ctx, 0, 0);
ctx.save();
ctx.globalAlpha = intro.boardOpacity;
ctx.translate(0, intro.boardOffsetY);
ctx.textBaseline = "top";
ctx.font = "10px NDS10";
// board
assets.user.snakeBoard.draw(ctx, BOARD_X, BOARD_Y);
if (state.value === "waiting") {
ctx.globalAlpha = intro.textOpacity;
ctx.fillStyle = "#fbfbfb";
const text = $t("settings.user.snake.startPrompt");
let x = 15;
let y = 37;
for (let i = 0; i < text.length; i += 1, x += 16) {
while (text[i] === "\n") {
x = 15;
y += 16;
i += 1;
}
ctx.fillText(text[i]!, x + 20, y);
}
} else {
// food
ctx.fillStyle = "#ff2020";
ctx.fillRect(
cellToBoardPos(food).x + 1,
cellToBoardPos(food).y + 1,
CELL_SIZE,
CELL_SIZE,
);
// snake
for (const part of tail) {
ctx.fillStyle = state.value === "dead" ? "#991010" : "#20ff20";
ctx.fillRect(
cellToBoardPos(part).x + 1,
cellToBoardPos(part).y + 1,
CELL_SIZE,
CELL_SIZE,
);
}
assets.user.snakeHead.draw(
ctx,
cellToBoardPos(position).x + 1,
cellToBoardPos(position).y,
);
}
ctx.restore();
});
onRender((ctx) => {
ctx.translate(0, intro.scoreOffsetY);
drawButton(ctx, $t("settings.user.snake.score", { score }), 10, 2, 118, true);
drawButton(
ctx,
$t("settings.user.snake.best", { best: highScore.value }),
138,
2,
108,
true,
);
}, 110);
useKeyDown(({ key }) => {
if (state.value !== "alive") return;
const newDirection = direction.clone();
switch (key) {
case "NDS_UP":
case "NDS_SWIPE_UP":
newDirection.set(0, -1);
break;
case "NDS_RIGHT":
case "NDS_SWIPE_RIGHT":
newDirection.set(1, 0);
break;
case "NDS_DOWN":
case "NDS_SWIPE_DOWN":
newDirection.set(0, 1);
break;
case "NDS_LEFT":
case "NDS_SWIPE_LEFT":
newDirection.set(-1, 0);
break;
}
if (newDirection.clone().dot(direction) === 0) {
nextDirection.copy(newDirection);
}
});
defineOptions({ render: () => null });
</script>
<template>
<CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="bLabel"
:a-label="aLabel"
@activate-b="handleActivateB"
@activate-a="handleActivateA"
/>
</template>