635 lines
16 KiB
Vue
635 lines
16 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 = () => {
|
|
if (isAnimating.value) return;
|
|
confirmationModal.open({
|
|
text: $t("settings.options.2048.quitConfirmation"),
|
|
bLabel: $t("common.no"),
|
|
aLabel: $t("common.yes"),
|
|
onClosed: async (choice) => {
|
|
if (choice === "A") {
|
|
await animateOutro();
|
|
store.closeSubMenu(true);
|
|
}
|
|
},
|
|
keepButtonsDown: (choice) => choice === "A",
|
|
});
|
|
};
|
|
|
|
const handleActivateA = () => {
|
|
if (isAnimating.value) return;
|
|
if (isDead()) {
|
|
resetBoard();
|
|
return;
|
|
}
|
|
confirmationModal.open({
|
|
text: $t("settings.options.2048.restartConfirmation"),
|
|
bLabel: $t("common.no"),
|
|
aLabel: $t("common.yes"),
|
|
onClosed: (choice) => {
|
|
if (choice === "A") {
|
|
resetBoard();
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const app = useAppStore();
|
|
|
|
const TILE_VALUES = [0, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096];
|
|
const APP_COLOR_INDEX = TILE_VALUES.indexOf(2048);
|
|
|
|
const buildTileColors = (
|
|
base: string,
|
|
): Record<number, { bg: string; fg: string }> => {
|
|
const result: Record<number, { bg: string; fg: string }> = {
|
|
[0]: { bg: "#f7f7f7", fg: "#776e65" },
|
|
};
|
|
|
|
for (let i = 1; i < TILE_VALUES.length; i += 1) {
|
|
const value = TILE_VALUES[i]!;
|
|
// 0 -> 1 = tile 2 -> 2048
|
|
const progress = (i - 1) / (APP_COLOR_INDEX - 1);
|
|
|
|
let lightnessBoost: number;
|
|
let chromaFactor: number;
|
|
|
|
if (i <= APP_COLOR_INDEX) {
|
|
lightnessBoost = (1 - progress) * 0.35;
|
|
chromaFactor = 0.05 + progress * 0.95;
|
|
} else {
|
|
const stepsAbove2048 = i - APP_COLOR_INDEX;
|
|
lightnessBoost = -stepsAbove2048 * 0.07;
|
|
chromaFactor = 1 + stepsAbove2048 * 0.15;
|
|
}
|
|
|
|
const bg = `oklch(from ${base} calc(l + ${lightnessBoost}) calc(c * ${chromaFactor}) h)`;
|
|
// change text color based on background lightness
|
|
const fg = `oklch(from ${bg} clamp(0, (0.6 - l) * 999, 1) 0 h)`;
|
|
result[value] = { bg, fg };
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const TILE_COLORS = computed(() => buildTileColors(app.color.hex));
|
|
const LAST_TILE_COLOR = computed(() => {
|
|
const values = Object.values(TILE_COLORS.value);
|
|
return values[values.length - 1]!;
|
|
});
|
|
const TILE_SIZE = 28;
|
|
const ANIM_DURATION = 0.1;
|
|
|
|
const BORDER_COLOR = computed(
|
|
() => `oklch(from ${app.color.hex} 0.88 0.015 h)`,
|
|
);
|
|
const BORDER_SIZE = 3;
|
|
|
|
const BOARD_X = 64;
|
|
const BOARD_Y = 32;
|
|
const BOARD_SIZE = 4;
|
|
|
|
const SLIDE_OFFSET = 96;
|
|
const SLIDE_DURATION = 0.25;
|
|
const SCORE_OFFSET = -20;
|
|
const SCORE_DURATION = 0.167;
|
|
|
|
const isAnimating = ref(true);
|
|
|
|
const bLabel = ref($t("common.goBack"));
|
|
const aLabel = ref($t("common.select"));
|
|
|
|
const intro = reactive({
|
|
frameOffsetY: SLIDE_OFFSET,
|
|
frameOpacity: 0,
|
|
scoreOffsetY: SCORE_OFFSET,
|
|
tilesVisible: false,
|
|
});
|
|
|
|
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"),
|
|
onActivateA: () => {},
|
|
onClosed: (choice) => {
|
|
if (choice === "A") {
|
|
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)" });
|
|
}
|
|
};
|
|
|
|
const animateIntro = async () => {
|
|
isAnimating.value = true;
|
|
buildTilesFromBoard();
|
|
|
|
await gsap
|
|
.timeline({
|
|
onComplete: () => {
|
|
isAnimating.value = false;
|
|
},
|
|
})
|
|
.to(intro, { frameOffsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0)
|
|
.to(intro, { frameOpacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0)
|
|
.call(
|
|
() => {
|
|
intro.tilesVisible = true;
|
|
animateSpawnAll();
|
|
},
|
|
[],
|
|
SLIDE_DURATION,
|
|
)
|
|
.to(
|
|
intro,
|
|
{ scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" },
|
|
SLIDE_DURATION,
|
|
)
|
|
.call(
|
|
() => {
|
|
bLabel.value = $t("common.quit");
|
|
aLabel.value = $t("common.restart");
|
|
},
|
|
[],
|
|
SUBMENU_LABEL_CHANGE_DELAY,
|
|
);
|
|
};
|
|
|
|
const animateOutro = async () => {
|
|
isAnimating.value = true;
|
|
await gsap
|
|
.timeline({
|
|
onComplete: () => {
|
|
isAnimating.value = false;
|
|
},
|
|
})
|
|
.to(
|
|
intro,
|
|
{ frameOffsetY: SLIDE_OFFSET, duration: SLIDE_DURATION, ease: "none" },
|
|
0,
|
|
)
|
|
.to(intro, { frameOpacity: 0, duration: SLIDE_DURATION, ease: "none" }, 0)
|
|
.to(
|
|
intro,
|
|
{ scoreOffsetY: SCORE_OFFSET, duration: SCORE_DURATION, ease: "none" },
|
|
0,
|
|
);
|
|
};
|
|
|
|
onMounted(() => {
|
|
animateIntro();
|
|
});
|
|
|
|
onRender((ctx) => {
|
|
ctx.save();
|
|
ctx.globalAlpha = intro.frameOpacity;
|
|
ctx.translate(BOARD_X, BOARD_Y + intro.frameOffsetY);
|
|
|
|
assets.images.settings.bottomScreen.options._2048.frame.draw(ctx, -3, -3);
|
|
|
|
ctx.fillStyle = BORDER_COLOR.value;
|
|
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.value[0]!.bg;
|
|
ctx.fillRect(cellX(col), cellY(row), TILE_SIZE, TILE_SIZE);
|
|
}
|
|
}
|
|
|
|
if (!intro.tilesVisible) {
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
for (const tile of tiles) {
|
|
const color = TILE_COLORS.value[tile.value] ?? LAST_TILE_COLOR.value;
|
|
|
|
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 + 9 : 10 + 7),
|
|
TILE_SIZE,
|
|
);
|
|
ctx.restore();
|
|
}
|
|
ctx.restore();
|
|
});
|
|
|
|
onRender((ctx) => {
|
|
ctx.translate(0, intro.scoreOffsetY);
|
|
drawButton(
|
|
ctx,
|
|
`${$t("settings.options.2048.score")}: ${score}`,
|
|
10,
|
|
2,
|
|
118,
|
|
true,
|
|
);
|
|
drawButton(
|
|
ctx,
|
|
`${$t("settings.options.2048.highScore")}: ${savedState.value.highScore}`,
|
|
138,
|
|
2,
|
|
108,
|
|
true,
|
|
);
|
|
}, 110);
|
|
|
|
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))) {
|
|
spawnTile();
|
|
spawnTile();
|
|
saveState();
|
|
}
|
|
|
|
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;
|
|
|
|
assets.audio.type.play(0.35);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (mergePairs.length > 0) {
|
|
const highestMerge = Math.max(...mergePairs.map((p) => p.mergedValue));
|
|
const boardMax = Math.max(0, ...beforeTiles.map((t) => t.value));
|
|
if (highestMerge > boardMax) {
|
|
assets.audio.duplicate.play(0.35);
|
|
}
|
|
}
|
|
|
|
const spawned = spawnTile();
|
|
saveState();
|
|
|
|
if (isDead()) {
|
|
buildTilesFromBoard();
|
|
assets.audio.invalid.play();
|
|
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 }) => {
|
|
if (isAnimating.value) return;
|
|
switch (key) {
|
|
case "NDS_UP":
|
|
case "NDS_SWIPE_UP":
|
|
slide(-1, 0);
|
|
break;
|
|
case "NDS_DOWN":
|
|
case "NDS_SWIPE_DOWN":
|
|
slide(1, 0);
|
|
break;
|
|
case "NDS_LEFT":
|
|
case "NDS_SWIPE_LEFT":
|
|
slide(0, -1);
|
|
break;
|
|
case "NDS_RIGHT":
|
|
case "NDS_SWIPE_RIGHT":
|
|
slide(0, 1);
|
|
break;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<CommonButtons
|
|
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
|
|
:b-label="bLabel"
|
|
:a-label="aLabel"
|
|
@activate-b="handleActivateB()"
|
|
@activate-a="handleActivateA()"
|
|
/>
|
|
</template>
|