<template>
  <transition appear name="game-container" @after-enter="onContainerEnterComplete">
    <div :style="containerSizeStyle" class="game-container" ref="gameContainer">
      <transition name="mask">
        <div
          v-show="gameStatus !== 'playing' && !hideMask"
          class="mask bg-white bg-op-50"
          @click="onMaskClick"
        >
          <div v-if="gameStatus === 'succeed'">
            <b>你赢了!</b>
            <div>🎉🥳🎊</div>
            <div style="font-size: 25px">点击关闭</div>
          </div>
          <div v-else>
            <b>你输了</b>
            <div>🤣👉🤡</div>
            <div style="font-size: 25px">点击关闭</div>
          </div>
        </div>
      </transition>
      <transition-group appear name="background-tile" @after-enter="onBackgroundTileEnterComplete">
        <template v-for="y in height">
          <div
            v-for="x in width"
            :key="`${y}-${x}`"
            :style="[
              getTilePosition(y - 1, x - 1),
              {
                animationDelay: `${0.1 * (x + y - 2)}s`,
              },
            ]"
            class="background-tile"
          ></div>
        </template>
      </transition-group>
      <div v-show="showTiles">
        <transition-group name="tile" type="animation" @after-enter="onTileEnterComplete">
          <template v-for="(row, y) in tileGrid">
            <template v-for="(tiles, x) in row">
              <template v-for="tile in tiles" :key="tile.id">
                <div
                  ref="tileDivs"
                  :class="[
                    'tile',
                    {
                      'tile-appear-from-others': tile.fromOthers,
                      'tile-transition-cancelled': transitionCancelled,
                    },
                  ]"
                  :data-tile-id="tile.id"
                  :style="[getTilePosition(y, x), getNumberTileStyle(tile)]"
                  @transitioncancel="onTileTransitionComplete"
                  @transitionend="onTileTransitionComplete"
                >
                  {{ tile.number }}
                </div>
              </template>
            </template>
          </template>
        </transition-group>
      </div>
    </div>
  </transition>
</template>
<style lang="scss" scoped>
@keyframes nil {
}

@keyframes container-appear {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes tile-common-appear {
  from {
    opacity: 0.5;
    transform: scale(0);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes tile-bounce-appear {
  from {
    opacity: 0.5;
    transform: scale(0.5);
  }
  80% {
    transform: scale(1.1);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

.game-container {
  position: relative;
  width: max-content;
  height: max-content;
  background-color: rgb(187, 174, 158);
  border-radius: 6px;
  margin-bottom: 10px;
}

.game-container-enter-active {
  animation: container-appear 0.5s ease both;
}

.mask {
  position: absolute;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 50px;
  text-align: center;
  color: rgb(119, 110, 101);
  z-index: 1;
  user-select: none;
}

.mask-enter-active {
  transition: opacity 0.3s ease;
}

.mask-enter-from {
  opacity: 0;
}

.mask-enter-to {
  opacity: 1;
}

.mask-leave-active {
  transition: opacity 0.15s ease;
}

.mask-leave-from {
  opacity: 1;
}

.mask-leave-to {
  opacity: 0;
}

.tile,
.background-tile {
  position: absolute;
  width: v-bind(tileWidthStyle);
  height: v-bind(tileWidthStyle);
  border-radius: 3px;
  user-select: none;
}

.tile {
  text-align: center;
  font-weight: bold;
  line-height: 100px;
  transition: all 0.1s ease-in;
}

.tile-enter-active.tile-appear-from-others:not(.tile-transition-cancelled) {
  animation: tile-bounce-appear 0.15s ease both;
}

.tile-enter-active:not(.tile-appear-from-others):not(.tile-transition-cancelled) {
  animation: tile-common-appear 0.15s ease both;
}

.tile-enter-active.tile-transition-cancelled {
  animation: nil 0s;
}

.background-tile {
  background-color: rgb(205, 193, 180);
}

.background-tile-enter-active {
  animation: tile-common-appear 0.2s ease both;
}
</style>
<script lang="ts" setup>
import { useGame2048Store, type GameState, type Tile } from '@/stores/2048';
import { chainIterables } from '@/utils';
import { get2DArrayItem } from '@/utils/array';
import { type Future, FutureMap } from '@/utils/future';
import { Directions } from '@/views/Game2048Page.vue';
import { useEventListener } from '@vueuse/core';
import { ElNotification } from 'element-plus';
import { add, pick, sample, shuffle } from 'lodash-es';
import { storeToRefs } from 'pinia';
import { computed, onMounted, onUnmounted, reactive, ref, useTemplateRef, watch } from 'vue';
const game2048Store = useGame2048Store();
type SingleTileLine = (Tile | undefined)[];
type OverlappedTileLine = Tile[][];
/**
 * 存储合并操作所有被合并的块.
 */
type TileRelationMap = Map<Tile, Tile[]>;

/**
 * 行合并结果.
 */
interface LineMergeResult {
  maxNumber: number;
  originalLine: SingleTileLine;
  transitionLine: OverlappedTileLine;
  resultLine: OverlappedTileLine;
  tileRelationMap: TileRelationMap;
}

/**
 * 数块是否添加完成?
 */
type TileAddFuture = Future;
/**
 * 数块过渡是否完成?
 */
type TileTransitionFuture = Future;
/**
 * 合并结果网格.
 */
type MergedGrid = (LineMergeResult | undefined)[];

const tileWidth = 105;
const tileWidthStyle = computed(() => `${tileWidth}px`);
const borderWidth = 15;
/**
 * 成功目标数字.
 */
const { width, height } = storeToRefs(game2048Store);
const containerSizeStyle = computed(() => ({
  minWidth: `${width.value * tileWidth + (width.value + 1) * borderWidth}px`,
  minHeight: `${height.value * tileWidth + (height.value + 1) * borderWidth}px`,
}));
/**
 * 是否显示容器.
 */
const showTiles = ref(false);
const tileDivs = ref<HTMLDivElement[]>([]);
/**
 * 游戏状态.
 */
const gameStatus = ref<GameState>('playing');
watch(gameStatus, (status) => {
  if (status == game2048Store.gameStatus) {
    return;
  }
  game2048Store.gameStatus = status;
  switch (status) {
    case 'succeed':
      ElNotification({
        title: '你赢了!',
        message: `单个块分数达到${game2048Store.successNumber}`,
        type: 'success',
        duration: 3500,
        position: 'bottom-left',
      });
      break;
    case 'failed':
      ElNotification({
        title: '你输了',
        message: '你无路可走',
        type: 'error',
        duration: 3500,
        position: 'bottom-left',
      });
      break;
  }
});
let isUnmounted = false;
const locked = ref(true);
const hideMask = ref(false);
const isTileTransitioning = ref(false);
const transitionCancelled = ref(false);
const gameReadyFuture: Future = Promise.withResolvers();
let enteredBackgroundTileCount = 0;
/**
 * 数块id分配器, 便于重用id.
 */
let maxId = 0;
const tileGrid = reactive<OverlappedTileLine[]>([]);
const tileAddFutureMap = new FutureMap();
const tileTransitionFutureMap = new FutureMap();
const tileBackgroundColorMapping: Record<number, string | undefined> = {
  2: 'rgb(238, 228, 218)',
  4: 'rgb(237, 224, 200)',
  8: 'rgb(242, 177, 121)',
  16: 'rgb(242, 177, 121)',
  32: 'rgb(246, 124, 95)',
  64: 'rgb(246, 94, 59)',
  128: 'rgb(237, 207, 114)',
  256: 'rgb(237, 204, 97)',
  512: 'rgb(237, 200, 80)',
  1024: 'rgb(237, 197, 63)',
  2048: 'rgb(237, 194, 46)',
};

/**
 * 持久化存储数块网格.
 */
function storeTileGrid() {
  game2048Store.rawTileGrid = tileGrid.map((row) =>
    row.map((tiles) => tiles.map((tile) => pick(tile, 'number', 'removed'))),
  );
}

/**
 * 获取块的位置.
 */
const getTilePosition = (y: number, x: number) => ({
  left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
  top: `${borderWidth + y * (tileWidth + borderWidth)}px`,
});

/**
 * 获取数块除位置以外的样式.
 */
const getNumberTileStyle = ({ number }: Tile) => ({
  fontSize: `${number < 128 ? 55 : number < 1024 ? 45 : 35}px`,
  color: number < 8 ? 'rgb(119, 110, 101)' : 'white',
  backgroundColor: tileBackgroundColorMapping[number] ?? 'rgb(56, 56, 56)',
});

/**
 * 添加随机数块, 空间不够则跳过.
 * @param randomCount 随机数量.
 * @param randomNumbers 随机的数字列表, 默认为2或4.
 */
function addRandomTiles(randomCount: number, randomNumbers: number[] = [2, 4]) {
  let emptyPositions: [y: number, x: number][] = [];
  for (const y of tileGrid.keys()) {
    for (const [x, tiles] of tileGrid[y].entries()) {
      if (tiles.length) {
        continue;
      }
      emptyPositions.push([y, x]);
    }
  }
  emptyPositions = shuffle(emptyPositions);
  const count = Math.min(emptyPositions.length, randomCount);
  const tiles: Tile[] = [];
  const tileAddPromises: TileAddFuture['promise'][] = [];
  for (let i = 0; i < count; i++) {
    const [y, x] = emptyPositions[i];
    const tile: Tile = {
      id: maxId++,
      number: sample(randomNumbers) ?? 2,
      removed: false,
      fromOthers: false,
    };
    tiles.push(tile);
    tileGrid[y][x].push(tile);
    tileAddPromises.push(tileAddFutureMap.add(tile.id));
  }
  return {
    tiles,
    promise: Promise.all(tileAddPromises).then<void>(() => undefined),
  };
}

/**
 * 获取数块Div的对应Id.
 * @param element 数块Div.
 */
const getElementTileId = (element: HTMLDivElement) => parseInt(element.dataset['tileId']!);

/**
 * 合并算法.
 * @param once 相邻块仅合并一次?
 */
function mergeLineImpl(tileRelationMap: TileRelationMap, line: SingleTileLine, once: boolean) {
  let changed = false;
  let finished;
  do {
    let prev: [number, Tile] | undefined = undefined;
    finished = true;
    for (const [i, tile] of [...line.entries()].reverse()) {
      if (tile === undefined) {
        continue;
      }
      if (prev === undefined) {
        prev = [i, tile];
      } else {
        const [prevIndex, prevTile] = prev;
        if (prevTile.number === tile.number) {
          finished = false;
          changed = true;
          prevTile.removed = true;
          tile.removed = true;
          const newTile: Tile = {
            id: maxId++,
            number: tile.number * 2,
            removed: false,
            fromOthers: true,
          };
          tileRelationMap.set(
            newTile,
            (tileRelationMap.get(prevTile) ?? [prevTile]).concat(
              tileRelationMap.get(tile) ?? [tile],
            ),
          );
          prev = once ? undefined : [i, newTile];
          line[i] = newTile;
          line[prevIndex] = undefined;
        } else {
          prev = [i, tile];
        }
      }
    }
  } while (!once && !finished);
  return changed;
}

/**
 * 合并一行.
 * @return 若该行未更改返回合并结果, 否则返回undefined.
 */
function mergeLine(line: SingleTileLine): [lineMergeREsult: LineMergeResult, changed: boolean] {
  const originalLine = [...line];
  const transitionLine: OverlappedTileLine = line.map(() => []);
  const tileRelationMap: TileRelationMap = new Map();
  let changed = mergeLineImpl(tileRelationMap, line, true);
  // 将所有块移到一侧.
  let dumpIndex = line.length - 1;
  let maxNumber = 0;
  for (const [i, tile] of [...line.entries()].reverse()) {
    if (tile === undefined) {
      continue;
    }
    if (i !== dumpIndex) {
      changed = true;
    }
    if (tile.number > maxNumber) {
      maxNumber = tile.number;
    }
    if (!tileRelationMap.has(tile)) {
      transitionLine[dumpIndex].push(tile);
    }
    line[i] = undefined;
    line[dumpIndex--] = tile;
  }
  for (const [i, tile] of line.entries()) {
    if (tile === undefined) {
      continue;
    }
    transitionLine[i].push(...(tileRelationMap.get(tile) ?? []));
  }
  return [
    {
      maxNumber,
      originalLine,
      transitionLine,
      resultLine: line.map((tile) => (tile !== undefined ? [tile] : [])),
      tileRelationMap,
    },
    changed,
  ];
}

/**
 * 合并所有块.
 * @param direction 合并方向
 * @return 是否有活动空间
 */
async function mergeTiles(direction: Directions) {
  const getMergedGrid = (
    d: Directions = direction,
  ): [MergedGrid, changed: boolean, maxNumber: number] => {
    let changed = false;
    let maxNumber = 0;
    const mergedGrid: MergedGrid = Array(firstIndices.length).fill(undefined);
    for (const first of firstIndices(d)) {
      const line = secondIndices(d).map((second) =>
        get2DArrayItem(tileGrid, first, second, isColFirst(d)).at(0),
      );

      const [lineMergeResult, lineChanged] = mergeLine(line);
      const { maxNumber: lineMaxNumber } = lineMergeResult;
      if (lineMaxNumber > maxNumber) {
        maxNumber = lineMaxNumber;
      }
      if (!lineChanged) {
        continue;
      }
      changed = true;
      mergedGrid[first] = lineMergeResult;
    }
    return [mergedGrid, changed, maxNumber];
  };

  const forEachMergedLine = (
    callback: (lineMergeResult: LineMergeResult, first: number) => void,
  ) => {
    for (const first of firstIndices()) {
      const mergeResult = mergedGrid[first];
      if (mergeResult === undefined) {
        continue;
      }
      callback(mergeResult, first);
    }
  };

  const forEachMergedTiles = (
    callback: (
      data: LineMergeResult & {
        tiles: Tile[];
        lineIndex: number;
      },
      first: number,
      second: number,
    ) => void,
  ) => {
    forEachMergedLine((lineMergeResult, first) => {
      for (const [lineIndex, second] of secondIndices().entries()) {
        const tiles = get2DArrayItem(tileGrid, first, second, isColFirst());
        callback(
          {
            tiles,
            lineIndex,
            ...lineMergeResult,
          },
          first,
          second,
        );
      }
    });
  };

  const firstIndices = (d: Directions = direction) =>
    Array.from(
      (function* () {
        switch (d) {
          case Directions.UP:
          case Directions.DOWN:
            for (let x = 0; x < width.value; x++) {
              yield x;
            }
            break;
          case Directions.LEFT:
          case Directions.RIGHT:
            for (let y = 0; y < height.value; y++) {
              yield y;
            }
            break;
        }
      })(),
    );

  const secondIndices = (d: Directions = direction) =>
    Array.from(
      (function* () {
        switch (d) {
          case Directions.UP:
            for (let y = height.value - 1; y >= 0; y--) {
              yield y;
            }
            break;
          case Directions.DOWN:
            for (let y = 0; y < height.value; y++) {
              yield y;
            }
            break;

          case Directions.LEFT:
            for (let x = width.value - 1; x >= 0; x--) {
              yield x;
            }
            break;
          case Directions.RIGHT:
            for (let x = 0; x < width.value; x++) {
              yield x;
            }
            break;
        }
      })(),
    );

  const isColFirst = (d: Directions = direction) => d == Directions.LEFT || d == Directions.RIGHT;

  const [mergedGrid, changed, maxNumber] = getMergedGrid(direction);
  if (maxNumber >= game2048Store.successNumber) {
    gameStatus.value = 'succeed';
  }
  const tileTransitionPromises: TileTransitionFuture['promise'][] = [];
  forEachMergedTiles(({ tiles, originalLine, transitionLine, lineIndex }) => {
    const transitionTiles = transitionLine[lineIndex];
    if (!transitionCancelled.value) {
      tileTransitionPromises.push(
        ...transitionTiles
          .filter((tile) => tile !== originalLine[lineIndex]) // 过滤没有移动位置的过渡块.
          .map((tile) => tileTransitionFutureMap.add(tile.id)),
      );
    }
    tiles.splice(0, tiles.length, ...transitionTiles);
  });
  await Promise.all(tileTransitionPromises);
  const tileAddPromises: TileAddFuture['promise'][] = [];
  forEachMergedTiles(({ tiles, originalLine, resultLine, lineIndex }) => {
    const resultTiles = resultLine[lineIndex];
    tileAddPromises.push(
      ...resultTiles
        .filter((tile) => !originalLine.includes(tile)) // 过滤仅移动位置的结果块.
        .map((tile) => tileAddFutureMap.add(tile.id)),
    );
    tiles.splice(0, tiles.length, ...resultTiles);
  });
  if (!isUnmounted) {
    game2048Store.maxNumber = maxNumber;
    forEachMergedLine(({ tileRelationMap }) => {
      game2048Store.score += [...tileRelationMap.keys()].map((tile) => tile.number).reduce(add, 0);
    });
  }
  await Promise.all(tileAddPromises.concat(addRandomTiles(changed ? 1 : 0).promise));
  return Object.values(Directions).some((d) => getMergedGrid(d)[1]);
}

function onMaskClick() {
  hideMask.value = true;
}

function onBackgroundTileEnterComplete() {
  if (++enteredBackgroundTileCount === width.value * height.value + 1) {
    gameReadyFuture?.resolve();
  }
}

function onContainerEnterComplete() {
  onBackgroundTileEnterComplete();
}

/**
 * 数块添加完成.
 * @param element 过渡完成的数块.
 */
function onTileEnterComplete(element: Element) {
  const tileId = getElementTileId(element as HTMLDivElement);
  tileAddFutureMap.resolve(tileId);
}

/**
 * 数块过渡完成.
 */
function onTileTransitionComplete(event: TransitionEvent) {
  const tileId = getElementTileId(event.currentTarget as HTMLDivElement);
  tileTransitionFutureMap.resolve(tileId);
}

async function move(key: string) {
  if (locked.value || gameStatus.value !== 'playing') {
    return;
  }
  switch (key) {
    case Directions.UP:
    case Directions.DOWN:
    case Directions.LEFT:
    case Directions.RIGHT:
      if (isTileTransitioning.value) {
        locked.value = true;
        transitionCancelled.value = true;
        for (const tileDiv of tileDivs.value) {
          for (const animation of tileDiv.getAnimations()) {
            animation.finish();
          }
        }
        await new Promise((resolve) => watch(isTileTransitioning, resolve, { once: true }));
        transitionCancelled.value = false;
        locked.value = false;
      }
      isTileTransitioning.value = true;
      if (!(await mergeTiles(key)) && gameStatus.value === 'playing') {
        gameStatus.value = 'failed';
      }
      if (gameStatus.value !== 'playing') {
        hideMask.value = false;
      }
      if (!isUnmounted) {
        storeTileGrid();
      }
      isTileTransitioning.value = false;
      break;
  }
}

const gameContainerRef = useTemplateRef('gameContainer');
type Pos = {
  x: number;
  y: number;
};
let startPos: Pos = { x: -1, y: -1 };
useEventListener(gameContainerRef, 'touchstart', (e) => {
  const firstTouch = e.changedTouches[0];
  startPos = { x: firstTouch.clientX, y: firstTouch.clientY };
});

const triggerMove = (deltaX: number, deltaY: number) => {
  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    if (deltaX > 0) {
      move(Directions.RIGHT);
    } else {
      move(Directions.LEFT);
    }
  } else {
    if (deltaY < 0) {
      move(Directions.UP);
    } else {
      move(Directions.DOWN);
    }
  }
};
useEventListener(gameContainerRef, 'touchend', (e) => {
  const firstTouch = e.changedTouches[0];
  const [x, y] = [firstTouch.clientX - startPos.x, firstTouch.clientY - startPos.y];
  triggerMove(x, y);
  startPos = { x: -1, y: -1 };
});

useEventListener(document, 'keydown', async (e) => {
  e.preventDefault();
  move(e.key);
});
onMounted(async () => {
  await gameReadyFuture.promise;
  const tileAddPromises: TileAddFuture['promise'][] = [];
  tileGrid.splice(
    0,
    0,
    ...game2048Store.rawTileGrid.map((row) =>
      row.map((rawTiles) =>
        rawTiles.map((rawTile) => {
          const id = maxId++;
          tileAddPromises.push(tileAddFutureMap.add(id));
          return {
            ...rawTile,
            id,
            fromOthers: false,
          };
        }),
      ),
    ),
  );
  if (game2048Store.isInitial) {
    const { tiles, promise: newTilePromise } = addRandomTiles(2);
    tileAddPromises.push(newTilePromise);
    game2048Store.maxNumber = Math.max(0, ...tiles.map((tile) => tile.number));
  }
  showTiles.value = true;
  await Promise.all(tileAddPromises);
  gameStatus.value = game2048Store.gameStatus;
  if (gameStatus.value === 'playing') {
    hideMask.value = true;
  }
  if (!isUnmounted) {
    storeTileGrid();
  }
  locked.value = false;
});
onUnmounted(() => {
  locked.value = true;
  isUnmounted = true;
  for (const future of chainIterables([
    tileAddFutureMap.values(),
    tileTransitionFutureMap.values(),
  ])) {
    future.resolve();
  }
});
</script>
