Files
pihkaal-me/app/components/Settings/BottomScreen/Menus/Options/2048.vue

517 lines
13 KiB
Vue

<script setup lang="ts">
import { useLocalStorage } from "@vueuse/core";
import gsap from "gsap";
const store = useSettingsStore();
const achievements = useAchievementsStore();
const confirmationModal = useConfirmationModal();
const { assets } = useAssets();
const { onRender } = useScreen();
const handleActivateB = () => {
confirmationModal.open({
text: $t("settings.options.2048.quitConfirmation"),
onConfirm: () => {},
onClosed: (choice) => {
if (choice === "confirm") store.closeSubMenu();
},
});
};
const handleActivateA = () => {
if (isDead()) {
resetBoard();
return;
}
confirmationModal.open({
text: $t("settings.options.2048.restartConfirmation"),
onConfirm: () => {},
onClosed: (choice) => {
if (choice === "confirm") {
resetBoard();
}
},
});
};
// TODO: one color scheme per app color
const TILE_COLORS: Record<number, { bg: string; fg: string }> = {
[0]: { bg: "#f7f7f7", fg: "#776e65" },
[2]: { bg: "#ebebf3", fg: "#292929" },
[4]: { bg: "#d3dbe3", fg: "#292929" },
[8]: { bg: "#bacbd3", fg: "#292929" },
[16]: { bg: "#a2bac3", fg: "#f9f6f2" },
[32]: { bg: "#8aa2b2", fg: "#f9f6f2" },
[64]: { bg: "#7192a2", fg: "#f9f6f2" },
[128]: { bg: "#698aa2", fg: "#f9f6f2" },
[256]: { bg: "#61829a", fg: "#f9f6f2" },
[512]: { bg: "#5c7b92", fg: "#f9f6f2" },
[1024]: { bg: "#57758a", fg: "#f9f6f2" }, // -2.5L, -0.8C
[2048]: { bg: "#476277", fg: "#f9f6f2" },
[4046]: { bg: "#173446", fg: "#f9f6f2" },
};
const LAST_TILE_COLOR =
Object.values(TILE_COLORS)[Object.values(TILE_COLORS).length - 1]!;
const TILE_SIZE = 28;
const ANIM_DURATION = 0.1;
const BORDER_COLOR = "#d7d7d7";
const BORDER_SIZE = 3;
const BOARD_X = 64;
const BOARD_Y = 32;
const BOARD_SIZE = 4;
const SCORE_X = 195;
const SCORE_Y = 36;
const HIGH_SCORE_Y = 68;
const emptyBoard = () =>
Array.from({ length: BOARD_SIZE }, () =>
Array.from({ length: BOARD_SIZE }, () => 0),
);
const savedState = useLocalStorage("nds-2048", {
board: emptyBoard(),
score: 0,
highScore: 0,
});
const board = savedState.value.board.map((r) => [...r]);
let score = savedState.value.score;
const saveState = () => {
savedState.value.board = board.map((r) => [...r]);
savedState.value.score = score;
if (score > savedState.value.highScore) {
savedState.value.highScore = score;
}
};
type VisualTile = { value: number; x: number; y: number; scale: number };
const cellX = (col: number) => BORDER_SIZE + col * (TILE_SIZE + BORDER_SIZE);
const cellY = (row: number) => BORDER_SIZE + row * (TILE_SIZE + BORDER_SIZE);
let tiles: VisualTile[] = [];
let animating = false;
const showRestartModal = () => {
confirmationModal.open({
text: $t("settings.options.2048.gameOver"),
onConfirm: () => {},
onClosed: (choice) => {
if (choice === "confirm") {
resetBoard();
}
},
});
};
const buildTilesFromBoard = () => {
tiles = [];
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
const value = board[row]![col]!;
if (value === 0) continue;
tiles.push({ value, x: cellX(col), y: cellY(row), scale: 1 });
}
}
};
const animateSpawnAll = () => {
for (const tile of tiles) {
tile.scale = 0;
gsap.to(tile, { scale: 1, duration: ANIM_DURATION, ease: "back.out(2)" });
}
};
onRender((ctx) => {
assets.images.home.topScreen.background.draw(ctx, 0, 0);
ctx.textBaseline = "top";
ctx.save();
ctx.translate(BOARD_X, BOARD_Y);
assets.images.settings.bottomScreen.options._2048.frame.draw(ctx, -3, -3);
ctx.fillStyle = BORDER_COLOR;
ctx.fillRect(
0,
0,
BORDER_SIZE * (BOARD_SIZE + 1) + TILE_SIZE * BOARD_SIZE,
BORDER_SIZE * (BOARD_SIZE + 1) + TILE_SIZE * BOARD_SIZE,
);
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
ctx.fillStyle = TILE_COLORS[0]!.bg;
ctx.fillRect(cellX(col), cellY(row), TILE_SIZE, TILE_SIZE);
}
}
for (const tile of tiles) {
const color = TILE_COLORS[tile.value] ?? LAST_TILE_COLOR;
ctx.save();
const cx = tile.x + TILE_SIZE / 2;
const cy = tile.y + TILE_SIZE / 2;
ctx.translate(cx, cy);
ctx.scale(tile.scale, tile.scale);
ctx.translate(-cx, -cy);
ctx.fillStyle = color.bg;
ctx.fillRect(tile.x, tile.y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = color.fg;
ctx.font = tile.value <= 2048 ? "10px NDS10" : "7px NDS7";
fillTextHCentered(
ctx,
tile.value.toString(),
tile.x + 1,
tile.y + (tile.value <= 2048 ? 9 : 10),
TILE_SIZE,
);
ctx.restore();
}
ctx.restore();
ctx.font = "7px NDS7";
ctx.fillStyle = "#010101";
// score
ctx.fillText(`${$t("settings.options.2048.score")}:`, SCORE_X, SCORE_Y);
ctx.fillText(score.toString(), SCORE_X, SCORE_Y + 16);
// high score
ctx.fillText(
`${$t("settings.options.2048.highScore")}:`,
SCORE_X,
HIGH_SCORE_Y,
);
ctx.fillText(
savedState.value.highScore.toString(),
SCORE_X,
HIGH_SCORE_Y + 16,
);
});
const getCell = (row: number, col: number) => board[row]![col]!;
const setCell = (row: number, col: number, val: number) => {
board[row]![col] = val;
};
const compact = (rowDir: number, colDir: number) => {
const reversed = rowDir > 0 || colDir > 0;
const start = reversed ? BOARD_SIZE - 1 : 0;
const step = reversed ? -1 : 1;
for (let major = 0; major < BOARD_SIZE; major += 1) {
for (let times = 0; times < BOARD_SIZE - 1; times += 1) {
for (let minor = start; minor >= 0 && minor < BOARD_SIZE; minor += step) {
const row = rowDir !== 0 ? minor : major;
const col = colDir !== 0 ? minor : major;
const nextRow = row - rowDir;
const nextCol = col - colDir;
if (nextRow < 0 || nextRow >= BOARD_SIZE) continue;
if (nextCol < 0 || nextCol >= BOARD_SIZE) continue;
if (getCell(row, col) === 0 && getCell(nextRow, nextCol) !== 0) {
setCell(row, col, getCell(nextRow, nextCol));
setCell(nextRow, nextCol, 0);
}
}
}
}
};
const merge = (rowDir: number, colDir: number) => {
const reversed = rowDir > 0 || colDir > 0;
const start = reversed ? BOARD_SIZE - 1 : 0;
const step = reversed ? -1 : 1;
for (let major = 0; major < BOARD_SIZE; major += 1) {
for (let minor = start; minor >= 0 && minor < BOARD_SIZE; minor += step) {
const row = rowDir !== 0 ? minor : major;
const col = colDir !== 0 ? minor : major;
const nextRow = row - rowDir;
const nextCol = col - colDir;
if (nextRow < 0 || nextRow >= BOARD_SIZE) continue;
if (nextCol < 0 || nextCol >= BOARD_SIZE) continue;
if (
getCell(row, col) !== 0 &&
getCell(row, col) === getCell(nextRow, nextCol)
) {
const merged = getCell(row, col) * 2;
setCell(row, col, merged);
setCell(nextRow, nextCol, 0);
score += merged;
}
}
}
};
const spawnTile = () => {
const empty: [number, number][] = [];
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (getCell(row, col) === 0) empty.push([row, col]);
}
}
if (empty.length === 0) return null;
const [row, col] = empty[Math.floor(Math.random() * empty.length)]!;
setCell(row, col, Math.random() < 0.9 ? 2 : 4);
return { row, col };
};
const resetBoard = () => {
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
setCell(row, col, 0);
}
}
score = 0;
spawnTile();
spawnTile();
saveState();
buildTilesFromBoard();
animateSpawnAll();
};
const isDead = () => {
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (
getCell(row, col) === 0 ||
(col < BOARD_SIZE - 1 && getCell(row, col) === getCell(row, col + 1)) ||
(row < BOARD_SIZE - 1 && getCell(row, col) === getCell(row + 1, col))
)
return false;
}
}
return true;
};
if (board.every((r) => r.every((c) => c === 0))) {
resetBoard();
} else {
buildTilesFromBoard();
animateSpawnAll();
}
const slide = (rowDir: number, colDir: number) => {
if (isDead() && !confirmationModal.isOpen) {
showRestartModal();
return;
}
if (animating) {
gsap.globalTimeline.clear();
animating = false;
buildTilesFromBoard();
}
const beforeTiles: { row: number; col: number; value: number }[] = [];
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (getCell(row, col) !== 0) {
beforeTiles.push({ row, col, value: getCell(row, col) });
}
}
}
compact(rowDir, colDir);
merge(rowDir, colDir);
compact(rowDir, colDir);
const afterTiles: { row: number; col: number; value: number }[] = [];
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (getCell(row, col) !== 0) {
afterTiles.push({ row, col, value: getCell(row, col) });
}
}
}
const changed =
beforeTiles.length !== afterTiles.length ||
afterTiles.some(
(t, i) =>
t.row !== beforeTiles[i]?.row ||
t.col !== beforeTiles[i]?.col ||
t.value !== beforeTiles[i]?.value,
);
if (!changed) return;
if (board.some((r) => r.some((c) => c >= 512))) {
achievements.unlock("2048_score_512");
}
const horizontal = colDir !== 0;
const reversed = rowDir > 0 || colDir > 0;
const animTiles: VisualTile[] = [];
const tweens: { tile: VisualTile; toX: number; toY: number }[] = [];
const mergePairs: {
keep: VisualTile;
remove: VisualTile;
mergedValue: number;
}[] = [];
for (let major = 0; major < BOARD_SIZE; major += 1) {
const lineBefore = beforeTiles
.filter((t) => (horizontal ? t.row : t.col) === major)
.sort((a, b) => {
const aPos = horizontal ? a.col : a.row;
const bPos = horizontal ? b.col : b.row;
return reversed ? bPos - aPos : aPos - bPos;
});
const lineAfter = afterTiles
.filter((t) => (horizontal ? t.row : t.col) === major)
.sort((a, b) => {
const aPos = horizontal ? a.col : a.row;
const bPos = horizontal ? b.col : b.row;
return reversed ? bPos - aPos : aPos - bPos;
});
let afterIdx = 0;
let i = 0;
while (i < lineBefore.length) {
const cur = lineBefore[i]!;
const next = lineBefore[i + 1];
const target = lineAfter[afterIdx]!;
const tile: VisualTile = {
value: cur.value,
x: cellX(cur.col),
y: cellY(cur.row),
scale: 1,
};
animTiles.push(tile);
tweens.push({ tile, toX: cellX(target.col), toY: cellY(target.row) });
if (next && next.value === cur.value && target.value === cur.value * 2) {
const mergeTile: VisualTile = {
value: next.value,
x: cellX(next.col),
y: cellY(next.row),
scale: 1,
};
animTiles.push(mergeTile);
tweens.push({
tile: mergeTile,
toX: cellX(target.col),
toY: cellY(target.row),
});
mergePairs.push({
keep: tile,
remove: mergeTile,
mergedValue: target.value,
});
i += 2;
} else {
i += 1;
}
afterIdx += 1;
}
}
const spawned = spawnTile();
saveState();
if (isDead()) {
buildTilesFromBoard();
showRestartModal();
return;
}
tiles = animTiles;
animating = true;
const tl = gsap.timeline({
onComplete: () => {
animating = false;
buildTilesFromBoard();
},
});
for (const { tile, toX, toY } of tweens) {
tl.to(tile, { x: toX, y: toY, duration: ANIM_DURATION, ease: "none" }, 0);
}
if (mergePairs.length > 0) {
tl.call(() => {
for (const { keep, remove, mergedValue } of mergePairs) {
keep.value = mergedValue;
tiles = tiles.filter((t) => t !== remove);
}
});
for (const { keep } of mergePairs) {
tl.fromTo(
keep,
{ scale: 1.1 },
{ scale: 1, duration: ANIM_DURATION, ease: "none" },
);
}
}
if (spawned) {
const spawnedTile: VisualTile = {
value: getCell(spawned.row, spawned.col),
x: cellX(spawned.col),
y: cellY(spawned.row),
scale: 0,
};
tl.call(() => {
tiles.push(spawnedTile);
});
tl.to(spawnedTile, {
scale: 1,
duration: ANIM_DURATION,
ease: "back.out(2)",
});
}
};
useKeyDown((key) => {
switch (key) {
// TODO: remove this, testing only
case "n":
savedState.value.board = [
[0, 0, 2, 4],
[8, 16, 32, 64],
[128, 256, 512, 1024],
[2048, 4096, 8192, 16384],
];
break;
case "NDS_UP":
slide(-1, 0);
break;
case "NDS_DOWN":
slide(1, 0);
break;
case "NDS_LEFT":
slide(0, -1);
break;
case "NDS_RIGHT":
slide(0, 1);
break;
}
});
</script>
<template>
<CommonButtons
:y-offset="confirmationModal.buttonsYOffset"
:b-label="$t('common.quit')"
:a-label="$t('common.restart')"
@activate-b="handleActivateB()"
@activate-a="handleActivateA()"
/>
</template>