All checks were successful
Build and Push Docker Image / build (push) Successful in 3m59s
443 lines
11 KiB
Vue
443 lines
11 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("nds-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(true);
|
|
} 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": {
|
|
atlas.audio.menuConfirmed.play();
|
|
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 = () => {
|
|
food.copy(randomFoodPos());
|
|
score += 1;
|
|
highScore.value = Math.max(highScore.value, score);
|
|
|
|
atlas.audio.duplicate.play(0.35);
|
|
|
|
if (score >= 25) {
|
|
achievements.unlock("snake_score_25");
|
|
}
|
|
};
|
|
|
|
const die = () => {
|
|
state.value = "dead";
|
|
atlas.audio.invalid.play();
|
|
};
|
|
|
|
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.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 + 9);
|
|
}
|
|
} else {
|
|
// food
|
|
const foodPos = cellToBoardPos(food);
|
|
ctx.fillStyle = "#ff2020";
|
|
ctx.fillRect(foodPos.x + 1, foodPos.y + 1, CELL_SIZE, CELL_SIZE);
|
|
|
|
// snake
|
|
const snakeParts = [...tail, position];
|
|
const dead = state.value === "dead";
|
|
const FILL = dead ? "#b84800" : "#ff8c00";
|
|
const FILL_DIM = dead ? "#8c3600" : "#cc7000";
|
|
|
|
const connectedTo = (i: number, dx: number, dy: number) => {
|
|
const part = snakeParts[i]!;
|
|
const neighbor = new THREE.Vector2(part.x + dx, part.y + dy);
|
|
const prev = snakeParts[i - 1];
|
|
const next = snakeParts[i + 1];
|
|
return (prev && prev.equals(neighbor)) || (next && next.equals(neighbor));
|
|
};
|
|
|
|
// fills
|
|
for (let i = 0; i < snakeParts.length; i++) {
|
|
const part = snakeParts[i]!;
|
|
const { x, y } = cellToBoardPos(part);
|
|
const px = x + 1;
|
|
const py = y + 1;
|
|
|
|
ctx.fillStyle = i === snakeParts.length - 1 ? FILL : FILL_DIM;
|
|
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
|
|
|
if (connectedTo(i, 1, 0)) {
|
|
ctx.fillRect(px + CELL_SIZE, py, CELL_PADDING, CELL_SIZE);
|
|
}
|
|
if (connectedTo(i, 0, 1)) {
|
|
ctx.fillRect(px, py + CELL_SIZE, CELL_SIZE, CELL_PADDING);
|
|
}
|
|
}
|
|
|
|
// outlines
|
|
ctx.fillStyle = "#ffffff";
|
|
for (let i = 0; i < snakeParts.length; i++) {
|
|
const part = snakeParts[i]!;
|
|
const { x, y } = cellToBoardPos(part);
|
|
const px = x + 1;
|
|
const py = y + 1;
|
|
|
|
if (!connectedTo(i, 0, -1)) {
|
|
ctx.fillRect(px, py, CELL_SIZE, 1);
|
|
}
|
|
if (!connectedTo(i, 0, 1)) {
|
|
ctx.fillRect(px, py + CELL_SIZE - 1, CELL_SIZE, 1);
|
|
}
|
|
if (!connectedTo(i, -1, 0)) {
|
|
ctx.fillRect(px, py, 1, CELL_SIZE);
|
|
}
|
|
if (!connectedTo(i, 1, 0)) {
|
|
ctx.fillRect(px + CELL_SIZE - 1, py, 1, CELL_SIZE);
|
|
}
|
|
}
|
|
|
|
const EYE_SIZE = 2;
|
|
const EYE_MARGIN = 3;
|
|
const eyeFar = CELL_SIZE - EYE_MARGIN - EYE_SIZE;
|
|
|
|
const head = snakeParts[snakeParts.length - 1]!;
|
|
const { x: headCellX, y: headCellY } = cellToBoardPos(head);
|
|
const headX = headCellX + 1;
|
|
const headY = headCellY + 1;
|
|
|
|
let eye1x: number, eye1y: number, eye2x: number, eye2y: number;
|
|
if (direction.x === 1) {
|
|
eye1x = headX + eyeFar;
|
|
eye1y = headY + EYE_MARGIN;
|
|
eye2x = headX + eyeFar;
|
|
eye2y = headY + eyeFar;
|
|
} else if (direction.x === -1) {
|
|
eye1x = headX + EYE_MARGIN;
|
|
eye1y = headY + EYE_MARGIN;
|
|
eye2x = headX + EYE_MARGIN;
|
|
eye2y = headY + eyeFar;
|
|
} else if (direction.y === 1) {
|
|
eye1x = headX + EYE_MARGIN;
|
|
eye1y = headY + eyeFar;
|
|
eye2x = headX + eyeFar;
|
|
eye2y = headY + eyeFar;
|
|
} else {
|
|
eye1x = headX + EYE_MARGIN;
|
|
eye1y = headY + EYE_MARGIN;
|
|
eye2x = headX + eyeFar;
|
|
eye2y = headY + EYE_MARGIN;
|
|
}
|
|
|
|
ctx.fillStyle = dead ? "#3d1500" : "#7a3800";
|
|
ctx.fillRect(eye1x, eye1y, EYE_SIZE, EYE_SIZE);
|
|
ctx.fillRect(eye2x, eye2y, EYE_SIZE, EYE_SIZE);
|
|
}
|
|
|
|
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.dot(direction) === 0) {
|
|
nextDirection.copy(newDirection);
|
|
atlas.audio.type.play(0.35);
|
|
}
|
|
});
|
|
|
|
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>
|