feat(settings/options/2048): animated tiles
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocalStorage } from "@vueuse/core";
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import gsap from "gsap";
|
||||||
|
|
||||||
const store = useSettingsStore();
|
const store = useSettingsStore();
|
||||||
const confirmationModal = useConfirmationModal();
|
const confirmationModal = useConfirmationModal();
|
||||||
@@ -16,11 +17,16 @@ const handleActivateA = () => {
|
|||||||
confirmationModal.close();
|
confirmationModal.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let confirmed = false;
|
||||||
confirmationModal.open({
|
confirmationModal.open({
|
||||||
text: "Restart game?",
|
text: "Restart game?",
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
|
confirmed = true;
|
||||||
|
},
|
||||||
|
onClosed: () => {
|
||||||
|
if (confirmed) {
|
||||||
resetBoard();
|
resetBoard();
|
||||||
confirmationModal.close();
|
}
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
confirmationModal.close();
|
confirmationModal.close();
|
||||||
@@ -46,6 +52,7 @@ const TILE_COLORS: Record<number, { bg: string; fg: string }> = {
|
|||||||
const LAST_TILE_COLOR =
|
const LAST_TILE_COLOR =
|
||||||
Object.values(TILE_COLORS)[Object.values(TILE_COLORS).length - 1]!;
|
Object.values(TILE_COLORS)[Object.values(TILE_COLORS).length - 1]!;
|
||||||
const TILE_SIZE = 28;
|
const TILE_SIZE = 28;
|
||||||
|
const ANIM_DURATION = 0.1;
|
||||||
|
|
||||||
const BORDER_COLOR = "#bbada0";
|
const BORDER_COLOR = "#bbada0";
|
||||||
const BORDER_SIZE = 3;
|
const BORDER_SIZE = 3;
|
||||||
@@ -66,12 +73,35 @@ const saveBoard = () => {
|
|||||||
savedBoard.value = board.map((r) => [...r]);
|
savedBoard.value = board.map((r) => [...r]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 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) => {
|
onRender((ctx) => {
|
||||||
assets.images.home.topScreen.background.draw(ctx, 0, 0);
|
assets.images.home.topScreen.background.draw(ctx, 0, 0);
|
||||||
|
|
||||||
ctx.font = "10px NDS10";
|
|
||||||
ctx.textBaseline = "top";
|
ctx.textBaseline = "top";
|
||||||
|
|
||||||
ctx.translate(BOARD_X, BOARD_Y);
|
ctx.translate(BOARD_X, BOARD_Y);
|
||||||
|
|
||||||
ctx.fillStyle = BORDER_COLOR;
|
ctx.fillStyle = BORDER_COLOR;
|
||||||
@@ -82,34 +112,37 @@ onRender((ctx) => {
|
|||||||
BORDER_SIZE * (BOARD_SIZE + 1) + TILE_SIZE * BOARD_SIZE,
|
BORDER_SIZE * (BOARD_SIZE + 1) + TILE_SIZE * BOARD_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTile = (col: number, row: number) => {
|
|
||||||
const value = board[row]![col]!;
|
|
||||||
const color = TILE_COLORS[value] ?? LAST_TILE_COLOR;
|
|
||||||
|
|
||||||
const x = BORDER_SIZE + col * (TILE_SIZE + BORDER_SIZE);
|
|
||||||
const y = BORDER_SIZE + row * (TILE_SIZE + BORDER_SIZE);
|
|
||||||
|
|
||||||
ctx.fillStyle = color.bg;
|
|
||||||
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
|
|
||||||
|
|
||||||
if (value === 0) return;
|
|
||||||
|
|
||||||
ctx.fillStyle = color.fg;
|
|
||||||
ctx.font = value <= 2048 ? "10px NDS10" : "7px NDS7";
|
|
||||||
fillTextHCentered(
|
|
||||||
ctx,
|
|
||||||
value.toString(),
|
|
||||||
x + 1,
|
|
||||||
y + (value <= 2048 ? 9 : 10),
|
|
||||||
TILE_SIZE,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let row = 0; row < BOARD_SIZE; row += 1) {
|
for (let row = 0; row < BOARD_SIZE; row += 1) {
|
||||||
for (let col = 0; col < BOARD_SIZE; col += 1) {
|
for (let col = 0; col < BOARD_SIZE; col += 1) {
|
||||||
renderTile(col, row);
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getCell = (row: number, col: number) => board[row]![col]!;
|
const getCell = (row: number, col: number) => board[row]![col]!;
|
||||||
@@ -175,10 +208,11 @@ const spawnTile = () => {
|
|||||||
if (getCell(row, col) === 0) empty.push([row, col]);
|
if (getCell(row, col) === 0) empty.push([row, col]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (empty.length === 0) return;
|
if (empty.length === 0) return null;
|
||||||
|
|
||||||
const [row, col] = empty[Math.floor(Math.random() * empty.length)]!;
|
const [row, col] = empty[Math.floor(Math.random() * empty.length)]!;
|
||||||
setCell(row, col, Math.random() < 0.9 ? 2 : 4);
|
setCell(row, col, Math.random() < 0.9 ? 2 : 4);
|
||||||
|
return { row, col };
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetBoard = () => {
|
const resetBoard = () => {
|
||||||
@@ -191,6 +225,8 @@ const resetBoard = () => {
|
|||||||
spawnTile();
|
spawnTile();
|
||||||
spawnTile();
|
spawnTile();
|
||||||
saveBoard();
|
saveBoard();
|
||||||
|
buildTilesFromBoard();
|
||||||
|
animateSpawnAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDead = () => {
|
const isDead = () => {
|
||||||
@@ -209,20 +245,126 @@ const isDead = () => {
|
|||||||
|
|
||||||
if (board.every((r) => r.every((c) => c === 0))) {
|
if (board.every((r) => r.every((c) => c === 0))) {
|
||||||
resetBoard();
|
resetBoard();
|
||||||
|
} else {
|
||||||
|
buildTilesFromBoard();
|
||||||
|
animateSpawnAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
const slide = (rowDir: number, colDir: number) => {
|
const slide = (rowDir: number, colDir: number) => {
|
||||||
const before = board.map((r) => [...r]);
|
if (animating) return;
|
||||||
|
|
||||||
|
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);
|
compact(rowDir, colDir);
|
||||||
merge(rowDir, colDir);
|
merge(rowDir, colDir);
|
||||||
compact(rowDir, colDir);
|
compact(rowDir, colDir);
|
||||||
|
|
||||||
const moved = board.some((r, i) => r.some((c, j) => c !== before[i]![j]));
|
const afterTiles: { row: number; col: number; value: number }[] = [];
|
||||||
if (!moved) return;
|
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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
spawnTile();
|
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;
|
||||||
|
|
||||||
|
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();
|
||||||
saveBoard();
|
saveBoard();
|
||||||
|
|
||||||
|
tiles = animTiles;
|
||||||
|
animating = true;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
animating = false;
|
||||||
|
buildTilesFromBoard();
|
||||||
if (isDead()) {
|
if (isDead()) {
|
||||||
confirmationModal.open({
|
confirmationModal.open({
|
||||||
text: "Game Over!\nRestart?",
|
text: "Game Over!\nRestart?",
|
||||||
@@ -235,6 +377,49 @@ const slide = (rowDir: number, colDir: number) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { tile, toX, toY } of tweens) {
|
||||||
|
tl.to(
|
||||||
|
tile,
|
||||||
|
{ x: toX, y: toY, duration: ANIM_DURATION, ease: "power1.out" },
|
||||||
|
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: "power1.out" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
useKeyDown((key) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user