Skip to content

2024 刘谦春晚魔术 《守岁共此时》

视频

魔术 视频

操作步骤

  • 步骤 1:选牌 随机选取 4 张扑克牌。
  • 步骤 2:洗牌 将牌随机打乱。
  • 步骤 3:撕牌 将扑克牌撕成两份。
  • 步骤 4:拼牌 将扑克牌并叠在一起。
  • 步骤 5:按姓名字数洗牌 将牌堆顶数量为 【名字字数】 的牌移至牌堆底。
  • 步骤 6:取三张放中间 将前三张牌放在牌堆中间。
  • 步骤 7:藏牌 取出牌堆顶的牌,放置在一旁。
  • 步骤 8:地域选择 取牌堆顶(南方人取 1 张北方人取 2 张不确定取三张)插入牌堆中间。
  • 步骤 9:性别选择 男生扔掉牌堆顶 1 张女生扔掉牌堆顶 2 张
  • 步骤 10:见证奇迹的时刻 执行“见证奇迹的时刻”循环,每说一个字,就取出牌堆顶一张牌放置在牌堆底。
  • 步骤 11:好运留下来,烦恼扔出去 从牌堆顶开始,每次先将牌堆顶的一张牌放在牌堆底,再扔掉牌堆顶的一张牌,重复以上操作直到只剩一张牌。
  • 完成:检查此牌和放置在一旁的牌是否吻合。 若吻合,则魔术成功

代码视频介绍

魔术代码实现 视频讲解

展示

随机选取 4张扑克牌

1 / 11 选牌

代码展示

主vue文件代码
vue
<template>
  <div class="atTimeGuardYear">
    <div class="buttons">
      <el-popover :visible="restartShow" placement="top" :width="280" trigger="click">
        <p>要重新开始魔术?</p>
        <div style="text-align: right">
          <el-button size="small" @click="restartShow = false">取消</el-button>
          <el-button size="small" type="primary" @click="restart">确定</el-button>
        </div>
        <template #reference>
          <el-button type="warning" @click="restartShow = true" :disabled="hasDoIt">
            重新开始
          </el-button>
        </template>
      </el-popover>
    </div>
    <div class="buttons">
      <el-button type="primary" @click="handlePrev" :disabled="!ctx.hasPrev || hasDoIt">
        上一步
      </el-button>
      <el-button type="primary" @click="handleNext" :disabled="!ctx.hasNext || hasDoIt">
        下一步
      </el-button>
    </div>
    <div class="operation">
      <div style="text-align: center" v-html="operationDescriptor?.title"></div>
      <div v-html="operationDescriptor?.getDesc"></div>

      <template v-if="operationDescriptor?.type !== 'none'">
        <template v-if="operationDescriptor?.type === 'radio'">
          <el-radio-group v-model="operationValue" v-if="!operationDescriptor.isDoIt">
            <el-radio-button v-for="item in operationDescriptor.payload.options" :label="item.value"
              :key="item.label">{{ item.label }}
            </el-radio-button>
          </el-radio-group>
        </template>

        <template v-else-if="operationDescriptor?.type === 'number'">
          <template v-if="!operationDescriptor.isDoIt">
            <el-input-number :max="operationDescriptor?.payload.max" :min="operationDescriptor?.payload.min"
              v-model="operationValue">
            </el-input-number>
          </template>
        </template>
        <el-button v-if="!operationDescriptor?.isDoIt" type="success" :disabled="!hasDoIt" @click="doIt">
          Just Do It!
        </el-button>
      </template>
    </div>
    <h1 class="title">
      {{ ctx.currentIndex + 1 }} / {{ ctx.stages.length }}
      {{ ctx.currentStage.name }}
    </h1>
    <div class="desk">
      <CardDesk v-bind="ctx.desk"></CardDesk>
    </div>
  </div>
</template>

<script setup lang="ts">
defineOptions({
  name: "atTimeGuardYear",
});
import CardDesk from "./CardDesk.vue";
import { reactive, ref, computed } from "vue";
import { InteractiveDescriptor, StageContext } from "./stages";
// 创建执行环境
let ctx = reactive(new StageContext(500));
const isLoading = ref(false);
// 中间操作值
const operationValue = ref(0);
// 操作默认状态
const operationDescriptor = ref<InteractiveDescriptor>({
  type: "none",
  title: "",
  payload: null,
  getOperation: () => void 0,
  isDoIt: false,
});
// 用于接收 Generator 的返回值
const opt = ref();
// 重新开始的弹窗
const restartShow = ref(false);
// 执型方法
const play = async () => {
  restartShow.value = false;
  if (isLoading.value) return;
  operationDescriptor.value = ctx.getInteractiveDescriptor();
  if (operationDescriptor.value.type === "none") {
    // 无交互操作
    isLoading.value = true;
    opt.value = operationDescriptor.value.getOperation();
    await ctx.play(opt.value);
    isLoading.value = false;
  } else {
    // 有交互操作
    if (operationDescriptor.value.isDoIt) {
      // 已经交互过
      await ctx.play();
    } else {
      // 未交互
      isLoading.value = true;
      opt.value = operationDescriptor.value.getOperation();
      operationValue.value = operationDescriptor.value.payload.defaultValue;
      // Generator 状态值
      opt.value.next();
    }
  }
};
play();
// 执行交互操作
const doIt = async () => {
  await ctx.play(opt.value.next(operationValue.value).value);
  isLoading.value = false;
};
// 下一步
const handleNext = async () => {
  ctx.next();
  play();
};
// 上一步
const handlePrev = () => {
  ctx.undo();
  ctx.prev();
  operationDescriptor.value = ctx.getInteractiveDescriptor();
  opt.value = operationDescriptor.value.getOperation();
};
// 重新开始
const restart = () => {
  ctx = reactive(new StageContext(500));
  play();
};
// 是否可以交互
const hasDoIt = computed(() => {
  return (
    isLoading.value ||
    (ctx.hasGetInteractiveDescriptor()
      ? false
      : !operationDescriptor.value.isDoIt)
  );
});
</script>

<style lang="scss" scoped>
.atTimeGuardYear {
  .buttons {
    display: flex;
    justify-content: space-around;
    margin-bottom: 16px;
  }

  .title {
    text-align: center;
    margin-top: 30px;
  }

  .desk {
    width: 100%;
    height: 200px;
    margin: 0 auto;
    /* background: lightblue; */
    margin-top: 50px;
  }

  .operation {
    height: 96px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-direction: column;

    &>* {
      margin-top: 10px;
    }
  }
}
</style>
Card.vue card组件代码
vue
<template>
  <div class="card" :class="card.tear" :key="card.cardId">
    <img :src="card.imagePath" :alt="card.value" :class="`card-img ${card.tear}`" />
  </div>
</template>

<script setup lang="ts">
import { Card } from './card';

const card = defineProps<Card>();
</script>

<style scoped>
.card {
  position: absolute;
  /* outline: 1px solid #ccc; */
  border-radius: 10px;
  overflow: hidden;
  transition: transform 0.5s, opacity 0.5s, left 0.5s, top 0.5s;
  box-shadow: 0.1rem 0.1rem 0.3rem var(--jk-box-shadow);
}

.card.keep-top {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

.card.keep-bottom {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

.card-img {
  width: 100%;
  position: absolute;
  left: 0;
  top: 0;
}

.card-img.keep-bottom {
  top: auto;
  bottom: 0;
}
</style>
CardDesk.vue card 魔术桌面代码
vue
<template>
  <div class="container" ref="containerEl">
    <div class="inner" v-if="isRendered">
      <TransitionGroup appear name="fade">
        <Card v-for="renderCard in renderCards" :key="renderCard.card.cardId" v-bind="renderCard.card" :style="{
          ...renderCard.styles,
        }"></Card>
      </TransitionGroup>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Desk } from './desk';
import { useResizeObserver } from '@vueuse/core';
import Card from './Card.vue';
import { useRenderCards } from './useRenderCards';
const desk = defineProps<Desk>();
const containerEl = ref<HTMLElement | null>(null);
const containerSize = reactive({ width: 0, height: 0 });
const isRendered = ref(false);
useResizeObserver(containerEl, (entries) => {
  const entry = entries[0];
  const { width, height } = entry.contentRect;
  containerSize.width = width;
  containerSize.height = height;
  isRendered.value = true;
});
const renderCards = useRenderCards(desk, isRendered, containerSize);
</script>

<style scoped>
.container {
  width: 100%;
  height: 100%;
}

.inner {
  width: 100%;
  height: 100%;
  position: relative;
}

.fade-enter-from {
  transform: translateY(-50px);
}

.fade-leave-to {
  transform: translateY(-50px);
}
</style>
stages.ts 步骤类代码
ts
import { createCard, createRandomCards } from './card';
import { Desk, initDesk } from './desk';
import { cloneDeep, random } from 'lodash-es';


// 模板字符串tag方法
function valueTag(strings: TemplateStringsArray, ...keys: any[]): string {
  let result = [strings[0]];
  keys.forEach((key, i) => {
    result.push(key, strings[i + 1]);
  });
  return result.join('');
}

// 延时函数
function delay(duration = 1000) {
  return new Promise((resolve) => {
    setTimeout(resolve, duration);
  });
}

// 交互描述符
export interface InteractiveDescriptor {
  type: 'radio' | 'number' | 'none';
  title: string;
  payload: any;
  isDoIt: boolean,
  getOperation(interactive?: any): any;
  getDesc?: string;
  value?: number,
  desc?: string;
}

// 步骤顺序
export class StageContext {
  public stages: Stage[];
  public currentIndex = 0;
  public desk: Desk = initDesk();
  constructor(public animationDuration: number) {
    this.stages = [
      new InitialStage(this),
      new ShuffleStage(this),
      new TearStage(this),
      new ConcatStage(this),
      new NameStage(this),
      new TakeThreeCardsToMiddle(this),
      new ConcealStage(this),
      new LocationChooseStage(this),
      new SexChooseStage(this),
      new SevenWordsSpellStage(this),
      new KeepOneStage(this),
    ];
  }
  get hasPrev() {
    return this.currentIndex > 0;
  }
  get hasNext() {
    return this.currentIndex < this.stages.length - 1;
  }
  get currentStage() {
    return this.stages[this.currentIndex];
  }
  // 获取当前交互描述内容
  getInteractiveDescriptor() {
    return this.currentStage.getInteractiveDescriptor();
  }
  // 执型动作
  async play(options?: any) {
    await this.currentStage.play(options);
  }
  undo() {
    this.currentStage.undo();
  }
  // 获取当前是否已经交互过
  hasGetInteractiveDescriptor() {
    return this.currentStage.isInteractiveDescriptor;
  }
  next() {
    if (this.hasNext) {
      this.currentIndex++;
    }
  }
  prev() {
    if (this.hasPrev) {
      this.currentIndex--;
    }
  }
}

export abstract class Stage {
  // 是否已经交互过判定
  private hasGetInteractiveDescriptor = false;
  // 初始状态
  private initialDesk: Desk | null = null;
  // 结束状态
  private playedDesk: Desk | null = null;
  // 交互描述默认内容
  protected defaultDescriptor: InteractiveDescriptor = {
    type: 'none',
    title: '',
    payload: null,
    getOperation: () => void 0,
    getDesc: '',
    isDoIt: false
  };
  constructor(public ctx: StageContext, public name: string) { }
  // 获取交互描述内容 带缓存
  getInteractiveDescriptor(): InteractiveDescriptor {
    if (this.hasGetInteractiveDescriptor) {
      return this.defaultDescriptor;
    }
    const desc = this._getInteractiveDescriptor();
    this.hasGetInteractiveDescriptor = true;
    return desc;
  }
  // 用于子级重写方法
  _getInteractiveDescriptor(): InteractiveDescriptor {
    return this.defaultDescriptor;
  }
  // 获取是否已经交互过
  get isInteractiveDescriptor() {
    return this.hasGetInteractiveDescriptor;
  }
  // 定义抽象方法 泳衣继承重写
  abstract _play(options: any): Promise<void> | void;
  async play(options: any): Promise<void> {
    if (!this.initialDesk) {
      this.initialDesk = cloneDeep(this.ctx.desk);
    }
    if (this.playedDesk) {
      this.ctx.desk = cloneDeep(this.playedDesk);
      return;
    }
    await this._play(options);
    this.playedDesk = cloneDeep(this.ctx.desk);
    return;
  }
  undo(): void {
    if (this.initialDesk) {
      this.ctx.desk = cloneDeep(this.initialDesk);
    }
  }
}

// 1/11 选牌
class InitialStage extends Stage {
  _play() {
    this.ctx.desk.line1Cards = createRandomCards(4);
  }
  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "随机选取 <strong>4张扑克牌</strong> "
    }
    return this.defaultDescriptor
  }
  constructor(ctx: StageContext) {
    super(ctx, '选牌');
  }
}

// 2/11 选牌
class ShuffleStage extends Stage {
  _play() {
    for (let i = this.ctx.desk.line1Cards.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * i);
      [this.ctx.desk.line1Cards[i], this.ctx.desk.line1Cards[j]] = [
        this.ctx.desk.line1Cards[j],
        this.ctx.desk.line1Cards[i],
      ];
    }
  }

  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "将扑克牌 <strong>随机打乱</strong>"
    }
    return this.defaultDescriptor
  }


  constructor(ctx: StageContext) {
    super(ctx, '洗牌');
  }
}
// 3/11 撕牌
class TearStage extends Stage {
  _play() {
    for (const card of this.ctx.desk.line1Cards) {
      card.tear = 'keep-top';
      this.ctx.desk.line2Cards.push(createCard(card.value, 'keep-bottom'));
    }
  }

  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "将扑克牌 <strong>撕成两份</strong>"
    }
    return this.defaultDescriptor
  }

  constructor(ctx: StageContext) {
    super(ctx, '撕牌');
  }

  public undo() {
    this.ctx.desk.line2Cards = [];
    setTimeout(() => {
      super.undo();
    }, this.ctx.animationDuration);
  }
}
// 4/11 拼牌
class ConcatStage extends Stage {
  _play() {
    this.ctx.desk.line1Cards = [
      ...this.ctx.desk.line1Cards,
      ...this.ctx.desk.line2Cards,
    ];
    this.ctx.desk.line2Cards = [];
  }

  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "将扑克牌 <strong>并叠在一起</strong>"
    }
    return this.defaultDescriptor
  }

  constructor(ctx: StageContext) {
    super(ctx, '拼牌');
  }
}

// 将牌放到牌堆底部
class TakeCardsToTail extends Stage {
  async _play(takeNumber: number) {
    for (let i = 0; i < takeNumber; i++) {
      this.beforeCardChange();
      const [head, ...rest] = this.ctx.desk.line1Cards;
      this.ctx.desk.line1Cards = [...rest, head];
      await delay(this.ctx.animationDuration);
    }
    this.afterChange();
  }

  beforeCardChange() { }
  afterChange() { }
}
// 5/11 按姓名字数洗牌
class NameStage extends TakeCardsToTail {
  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      title: '你的姓名有几个字?',
      type: 'number',
      payload: {
        defaultValue: 3,
        min: 1,
        max: 10,
      },
      isDoIt: false,
      getOperation: function* () {
        let value: number
        value = yield
        this.value = value
        this.isDoIt = true
        this.getDesc = valueTag`填写的,名字 <strong>长度为${this.value}</strong> ,取 <em>${this.value}张牌</em> 放置到牌堆底部`
        return value
      },
    }
    return this.defaultDescriptor;
  }
  constructor(ctx: StageContext) {
    super(ctx, '按姓名字数洗牌');
  }
}

// 将牌放到牌堆中间
abstract class InsertStage extends Stage {
  _play(takeNumber: number) {
    const cards = this.ctx.desk.line1Cards;
    if (takeNumber > cards.length - 2 || takeNumber <= 0) {
      return;
    }
    const insertFromTopNumber = random(1, cards.length - takeNumber - 1);
    const lastChangeIndex = takeNumber + insertFromTopNumber - 1;
    for (let i = takeNumber; i <= lastChangeIndex; i++) {
      for (let j = i, k = 0; k < takeNumber; k++, j--) {
        // 交换j和j-1
        [cards[j], cards[j - 1]] = [cards[j - 1], cards[j]];
      }
    }
  }
}
// 6/11 取三张放中间
class TakeThreeCardsToMiddle extends InsertStage {
  constructor(ctx: StageContext) {
    super(ctx, '取三张放中间');
  }
  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "将 <strong>前三张牌</strong> 放在 <strong>牌堆中间</strong> ",
      getOperation: () => 3,
    };
    return this.defaultDescriptor;
  }
}
// 7/11 藏牌
class ConcealStage extends Stage {
  _play() {
    this.ctx.desk.concealCard = this.ctx.desk.line1Cards.shift()!;
  }

  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "取出 <strong>牌堆顶的牌</strong> ,<strong>放置一旁</strong>",
    };
    return this.defaultDescriptor;
  }

  constructor(ctx: StageContext) {
    super(ctx, '藏牌');
  }
}
// 8/11 地域选择
class LocationChooseStage extends InsertStage {
  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      title: '请选择你的地域',
      type: 'radio',
      payload: {
        defaultValue: 1,
        options: [
          { label: '南方人', value: 1 },
          { label: '北方人', value: 2 },
          { label: '不清楚', value: 3 },
        ],
      },
      isDoIt: false,
      getOperation: function* () {
        let value: number
        this.value = value = yield
        this.isDoIt = true
        this.getDesc = valueTag`<strong>${this.payload.options.find((item: any) => this.value === item.value).label}</strong> ,取 <em>${this.value}</em> 张牌插入 <em>牌堆中间</em> `
        return value
      }
    };
    return this.defaultDescriptor;
  }
  constructor(ctx: StageContext) {
    super(ctx, '地域选择');
  }
}

// 丢牌
class ThrowHeadCardsStage extends Stage {
  async _play(takeNumber: number) {
    for (let i = 0; i < takeNumber; i++) {
      this.ctx.desk.line1Cards.shift();
      await delay(this.ctx.animationDuration);
    }
  }
}
// 9/11 性别选择
class SexChooseStage extends ThrowHeadCardsStage {
  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      title: '请选择你的性别',
      type: 'radio',
      payload: {
        defaultValue: 1,
        options: [
          { label: '男', value: 1 },
          { label: '女', value: 2 },
        ],
      },
      isDoIt: false,
      getOperation: function* () {
        let value: number
        this.value = value = yield
        this.isDoIt = true
        this.getDesc = valueTag` <strong>${this.payload.options.find((item: any) => this.value === item.value).label}生</strong> ,<em>丢弃牌堆顶 ${this.value}</em> 张牌`
        return value
      }
    };
    return this.defaultDescriptor;
  }
  constructor(ctx: StageContext) {
    super(ctx, '性别选择');
  }
}
// 10/11 见证奇迹的时刻
class SevenWordsSpellStage extends TakeCardsToTail {
  private words: string;
  private currentIndex = 0;
  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "念出 <strong>“见证奇迹的时刻”</strong> 7个字,<br/> 每念一个字,就 <em>取牌堆顶一张牌放置在牌堆底</em>",
      getOperation: () => 7,
    };
    return this.defaultDescriptor;
  }
  beforeCardChange(): void {
    this.name = this.words[this.currentIndex++];
  }
  afterChange(): void {
    this.currentIndex = 0;
    this.name = this.words;
  }
  constructor(ctx: StageContext) {
    super(ctx, '见证奇迹的时刻');
    this.words = this.name;
  }
}
// 11/11 好运留下来,烦恼丢出去
class KeepOneStage extends Stage {
  private words: string[];
  async _play() {
    const stages = [this.takeStage, this.throwStage];
    let i = 0;
    let oldDuration = this.ctx.animationDuration;
    this.ctx.animationDuration *= 2;
    let wordsIndex = 0;
    while (this.ctx.desk.line1Cards.length > 1) {
      const stage = stages[i++ % stages.length];
      this.name = this.words[wordsIndex++ % this.words.length];
      await stage._play(1);
    }
    this.ctx.desk.meet = [
      this.ctx.desk.line1Cards[0],
      this.ctx.desk.concealCard!,
    ];
    this.ctx.desk.concealCard = null;
    this.ctx.desk.line1Cards = [];
    this.ctx.animationDuration = oldDuration;
    this.name = this.words.join(',');
  }

  _getInteractiveDescriptor(): InteractiveDescriptor {
    this.defaultDescriptor = {
      ...this.defaultDescriptor,
      title: "重复<strong>“好运留下来,烦恼扔出去”</strong>,<br/>从牌堆顶开始,<br/>“好运留下来”将<em>堆顶的牌放在堆底</em>,<br/>“烦恼扔出去”<em>扔掉堆顶的一张牌</em>,<br/><strong>重复以上操作</strong>直到<em>只剩一张牌</em>",
    }
    return this.defaultDescriptor;
  }

  private throwStage = new ThrowHeadCardsStage(this.ctx, '');
  private takeStage = new TakeCardsToTail(this.ctx, '');
  constructor(ctx: StageContext) {
    super(ctx, '好运留下来,烦恼丢出去');
    this.words = this.name.split(",")
  }
}
useRenderCards Hooks代码
ts
import { Ref, reactive, watchEffect } from 'vue';
import { Card } from './card';
import { Desk } from './desk';

export interface RenderCard {
  card: Card;
  styles: object;
}
const cardSize = {
  width: 80,
  height: 122,
};

function findRenderCard(renderCards: RenderCard[], card: Card) {
  return renderCards.find((item) => item.card.cardId === card.cardId);
}

function findCard(cards: Card[], renderCard: RenderCard) {
  return cards.find((item) => item.cardId === renderCard.card.cardId);
}

export function useRenderCards(
  desk: Desk,
  isRender: Ref<boolean>,
  containerSize: { width: number; height: number }
): RenderCard[] {
  const renderCards = reactive<RenderCard[]>([]);
  function updateRenderCards(lineCards: Card[], lineIndex: 0 | 1) {
    const len = lineCards.length;
    for (let i = 0; i < len; i++) {
      const card = lineCards[i];
      // 确定横坐标
      const space = containerSize.width - cardSize.width;
      let step = space / (len - 1);
      if (!isFinite(step)) {
        step = 0;
      }
      const left = i * step + 'px';
      // 确定纵坐标
      let top = '0px';
      if (lineIndex === 1) {
        top = cardSize.height / 2 + 50 + 'px';
      }
      // 确定宽高
      const width = cardSize.width + 'px';
      let h = cardSize.height;
      if (card.tear !== 'none') {
        h /= 2;
      }
      const height = h + 'px';
      // 确定 z-index
      const zIndex = i + 1;
      const styles = {
        left,
        top,
        width,
        height,
        zIndex,
      };
      // 找到对应的渲染卡片
      const renderCard = findRenderCard(renderCards, card);
      if (renderCard) {
        renderCard.styles = styles;
        renderCard.card.tear = card.tear;
      } else {
        renderCards.push({
          card,
          styles,
        });
      }
    }
  }
  watchEffect(() => {
    if (!isRender.value) {
      return;
    }
    updateRenderCards(desk.line1Cards, 0);
    updateRenderCards(desk.line2Cards, 1);
    // 处理藏牌
    if (desk.concealCard) {
      const styles = {
        left: '0px',
        bottom: '0px',
        width: cardSize.width + 'px',
        height: cardSize.height + 'px',
        zIndex: 999,
      };
      if (desk.concealCard.tear !== 'none') {
        styles.height = cardSize.height / 2 + 'px';
      }
      const renderCard = findRenderCard(renderCards, desk.concealCard);
      if (renderCard) {
        renderCard.styles = styles;
      } else {
        renderCards.push({
          card: desk.concealCard,
          styles,
        });
      }
    }

    // 处理删除的卡片
    for (let i = renderCards.length - 1; i >= 0; i--) {
      const renderCard = renderCards[i];
      const card =
        findCard(desk.line1Cards, renderCard) ||
        findCard(desk.line2Cards, renderCard) ||
        findCard(desk.meet || [], renderCard);
      if (!card && desk.concealCard?.cardId !== renderCard.card.cardId) {
        renderCards.splice(i, 1);
      }
    }
    // 处理遇牌
    if (desk.meet) {
      const x = containerSize.width / 2 - cardSize.width / 2;
      const y1 = containerSize.height / 2 - cardSize.height;
      const y2 = containerSize.height / 2 - cardSize.height / 2;
      const styles1 = {
        left: x + 'px',
        top: y1 + 'px',
        width: cardSize.width + 'px',
        height: cardSize.height / 2 + 'px',
        zIndex: 999,
      };
      const styles2 = {
        ...styles1,
        top: y2 + 'px',
      };
      renderCards.find((c) => c.card.tear === 'keep-top')!.styles = styles1;
      renderCards.find((c) => c.card.tear === 'keep-bottom')!.styles = styles2;
    }
  });
  return renderCards;
}
card.ts card类 代码
ts
const cardValues = [
  // 红心
  '♥A',
  '♥2',
  '♥3',
  '♥4',
  '♥5',
  '♥6',
  '♥7',
  '♥8',
  '♥9',
  '♥10',
  '♥J',
  '♥Q',
  '♥K',
  // 黑桃
  '♠A',
  '♠2',
  '♠3',
  '♠4',
  '♠5',
  '♠6',
  '♠7',
  '♠8',
  '♠9',
  '♠10',
  '♠J',
  '♠Q',
  '♠K',
  // 方块
  '♦A',
  '♦2',
  '♦3',
  '♦4',
  '♦5',
  '♦6',
  '♦7',
  '♦8',
  '♦9',
  '♦10',
  '♦J',
  '♦Q',
  '♦K',
  // 梅花
  '♣A',
  '♣2',
  '♣3',
  '♣4',
  '♣5',
  '♣6',
  '♣7',
  '♣8',
  '♣9',
  '♣10',
  '♣J',
  '♣Q',
  '♣K',
  // 王牌
  'Black Joker',
  'Red Joker',
] as const;

type CardValues = (typeof cardValues)[number];
export type TearType = 'none' | 'keep-top' | 'keep-bottom';
export interface Card {
  readonly value: CardValues;
  readonly imagePath: string;
  readonly cardId: number;
  tear: TearType;
}
let uniqueCardId = 1;
const colorMap = {
  '♦': '4',
  '♥': '2',
  '♠': '1',
  '♣': '3',
};
const valueMap = {
  A: '1',
  '2': '2',
  '3': '3',
  '4': '4',
  '5': '5',
  '6': '6',
  '7': '7',
  '8': '8',
  '9': '9',
  '10': '0',
  J: 'a',
  Q: 'b',
  K: 'c',
  'Red Joker': 'a',
  'Black Joker': 'b',
};
export function createCard(
  cardValue: CardValues,
  tear: TearType = 'none'
): Card {
  const cardId = uniqueCardId++;
  const isJoker = cardValue === 'Red Joker' || cardValue === 'Black Joker';
  const value = isJoker
    ? valueMap[cardValue]
    : valueMap[cardValue.slice(1) as keyof typeof valueMap];
  const color = isJoker ? '' : colorMap[cardValue[0] as keyof typeof colorMap];
  const name = `${color}${value}`;
  // const url = new URL(`/assets/demo/atTimeGuardYear/${name}.jpg`, import.meta.url);
  const imagePath = `/assets/demo/atTimeGuardYear/${name}.jpg`;
  return {
    value: cardValue,
    imagePath,
    cardId,
    tear,
  };
}
// 随机抽牌
export function createRandomCards(count: number): Card[] {
  if (count > cardValues.length) {
    throw new Error(`count should be less than ${cardValues.length}`);
  }
  if (count < 0) {
    throw new Error(`count should be greater than 0`);
  }
  const indexes = new Set<number>();
  while (indexes.size < count) {
    indexes.add(Math.floor(Math.random() * cardValues.length));
  }
  return Array.from(indexes).map((index) => createCard(cardValues[index]));
}
desk.ts 魔术桌面代码
ts
import { Card } from './card';
export interface Desk {
  line1Cards: Card[];
  line2Cards: Card[];
  concealCard: Card | null;
  meet: [Card, Card] | null;
}

export function initDesk(): Desk {
  return {
    line1Cards: [],
    line2Cards: [],
    concealCard: null,
    meet: null,
  };
}