Repository: goplus/builder
Pull request #1478: refactor: Split StepPlayer into CodingStep and FollowingStep
Author: login: neverbiasu
Created: 2025-03-18T07:41:45Z | Merged: 2025-03-19T07:05:11Z

@nigha 寒星老师，这里把StepPlayer拆成了step子目录下的StepPlayer、CodingStep和FollowingStep三个组件。


Diff:
diff --git a/spx-gui/src/components/guidance/NodeTaskPlayer.vue b/spx-gui/src/components/guidance/NodeTaskPlayer.vue
index 9ca973719..08cd24a85 100644
--- a/spx-gui/src/components/guidance/NodeTaskPlayer.vue
+++ b/spx-gui/src/components/guidance/NodeTaskPlayer.vue
@@ -8,7 +8,7 @@
 <script lang="ts" setup>
 import type { NodeTask, Step } from '@/apis/guidance'
 import { ref, computed } from 'vue'
-import StepPlayer from './StepPlayer.vue'
+import StepPlayer from './step/StepPlayer.vue'
 
 const props = defineProps<{ nodeTask: NodeTask }>()
 
diff --git a/spx-gui/src/components/guidance/StepPlayer.vue b/spx-gui/src/components/guidance/StepPlayer.vue
deleted file mode 100644
index 7b0bdfc20..000000000
--- a/spx-gui/src/components/guidance/StepPlayer.vue
+++ /dev/null
@@ -1,835 +0,0 @@
-<template>
-  <div class="step-player">
-    <MaskWithHighlight :visible="true" :highlight-element-path="props.step.target">
-      <template v-if="props.step.type === 'coding'">
-        <div class="code-button-container">
-          <UIButton type="success" size="medium" @click="handleCheckButtonClick">
-            {{ t({ zh: '检查', en: 'Check' }) }}
-          </UIButton>
-          <UIButton type="primary" size="medium" @click="handleInfoButtonClick">
-            {{ t({ zh: '信息', en: 'Info' }) }}
-          </UIButton>
-          <UIButton type="secondary" size="medium" @click="handleAnswerButtonClick">
-            {{ t({ zh: '答案', en: 'Answer' }) }}
-          </UIButton>
-        </div>
-        <div class="suggestion-box">
-          <ResultDialog
-            :visible="isCheckingDialogVisible"
-            :title="t({ zh: '代码检查中', en: 'Checking code' })"
-            :content="''"
-            :loading="true"
-          >
-          </ResultDialog>
-          <ResultDialog
-            :visible="isNextDialogVisible"
-            :title="t({ zh: '检测结果', en: 'Check Result' })"
-            :content="t({ zh: '太棒了！你的代码检测通过！', en: 'Great! Your code check passed!' })"
-            :button="t({ zh: '下一步', en: 'Next' })"
-            :button-action="'next'"
-            @next="handleNextButtonClick"
-          >
-          </ResultDialog>
-          <ResultDialog
-            :visible="isRetryDialogVisible"
-            :title="t({ zh: '检测结果', en: 'Check Result' })"
-            :content="t({ zh: '错误\n', en: 'Error\n' }) + props.step.tip.zh"
-            :button="t({ zh: '重试', en: 'Retry' })"
-            :button-action="'retry'"
-            @retry="handleRetryButtonClick"
-          >
-          </ResultDialog>
-          <ResultDialog
-            :visible="isAnswerDialogVisible"
-            :title="t({ zh: '参考答案', en: 'Reference Answer' })"
-            :content="''"
-            :is-code="true"
-            :button="t({ zh: '关闭', en: 'Close' })"
-            :button-action="'close'"
-            :code="answer || ''"
-            @close="handleAnswerCloseButtonClick"
-          >
-          </ResultDialog>
-          <ResultDialog
-            :visible="isInfoDialogVisible"
-            :title="t({ zh: '当前步骤', en: 'Current Step' })"
-            :content="props.step.description.zh"
-            :button="t({ zh: '关闭', en: 'Close' })"
-            :button-action="'close'"
-            @close="handleInfoCloseButtonClick"
-          >
-          </ResultDialog>
-          <ResultDialog
-            :visible="isTimeoutDialogVisible"
-            :title="t({ zh: '温馨提醒', en: 'Friendly Reminder' })"
-            :content="
-              t({
-                zh: '牛小七发现你卡顿好久了...可以试着点击下方按钮直接查看答案哦～',
-                en: 'Niuxiaoqi found you stuck for a long time... You can click the button below to view the answer directly~'
-              })
-            "
-            :button="t({ zh: '查看答案', en: 'View Answer' })"
-            :button-action="'answer'"
-            @answer="handleAnswerFromTimeout"
-          >
-          </ResultDialog>
-        </div>
-      </template>
-      <template v-if="props.step.type === 'following'" #default="{ slotInfo }">
-        <div class="guide-ui-container">
-          <img
-            class="niuxiaoqi"
-            :style="getNiuxiaoqiStyle(slotInfo)"
-            width="300"
-            height="320"
-            src="https://www-static.qbox.me/sem/pili-live-1001/source/img/qiniu.png"
-          />
-          <svg
-            class="ic-arrow"
-            :style="getArrowStyle(slotInfo)"
-            viewBox="0 0 1024 1024"
-            version="1.1"
-            xmlns="http://www.w3.org/2000/svg"
-            p-id="1444"
-            data-spm-anchor-id="a313x.search_index.0.i0.6e8f3a81bBVDWW"
-            width="300"
-            height="200"
-          >
-            <path
-              d="M149.333333 896a21.333333 21.333333 0 0 1-15.086666-36.42L823.166667 170.666667H661.333333a21.333333 21.333333 0 0 1 0-42.666667h213.46a21.333333 21.333333 0 0 1 21.206667 21.206667V362.666667a21.333333 21.333333 0 0 1-42.666667 0V200.833333L164.42 889.753333A21.266667 21.266667 0 0 1 149.333333 896z"
-              fill="#5C5C66"
-              class="icon-fill"
-              p-id="1445"
-            ></path>
-          </svg>
-          <div class="bubble-container" :style="getBubbleContainerStyle(slotInfo)">
-            <svg
-              class="ic-bubble-bg"
-              :style="getBubbleBgStyle(slotInfo)"
-              viewBox="0 0 1536 1024"
-              xmlns="http://www.w3.org/2000/svg"
-            >
-              <path
-                d="M269.824 1024H0l122.481778-133.461333V56.888889a56.888889 56.888889 0 0 1 56.888889-56.888889H1479.111111a56.888889 56.888889 0 0 1 56.888889 56.888889v910.222222a56.888889 56.888889 0 0 1-56.888889 56.888889z"
-                class="svg-fill"
-              ></path>
-            </svg>
-            <div v-if="props.step.tip" class="bubble-tip">
-              {{ t({ zh: props.step.tip.zh, en: props.step.tip.en }) }}
-            </div>
-          </div>
-        </div>
-      </template>
-    </MaskWithHighlight>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { onMounted, onBeforeUnmount, ref, nextTick } from 'vue'
-import { toText, type Files } from '@/models/common/file'
-import { useI18n } from '@/utils/i18n'
-import { useTag } from '@/utils/tagging'
-import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
-import MaskWithHighlight from '@/components/common/MaskWithHighlight.vue'
-import ResultDialog from './ResultDialog.vue'
-import UIButton from '@/components/ui/UIButton.vue'
-import type { HighlightRect } from '@/components/common/MaskWithHighlight.vue'
-import type { Step } from '@/apis/guidance'
-import { getFiles } from '@/models/common/cloud'
-import type { FileCollection } from '@/apis/common'
-
-const editorCtx = useEditorCtx()
-const filter = editorCtx.listFilter
-const props = defineProps<{
-  step: Step
-}>()
-
-const emit = defineEmits<{
-  stepCompleted: []
-}>()
-
-const isCheckingDialogVisible = ref(false)
-const isNextDialogVisible = ref(false)
-const isRetryDialogVisible = ref(false)
-const isAnswerDialogVisible = ref(false)
-const isInfoDialogVisible = ref(false)
-const isTimeoutDialogVisible = ref(false)
-
-const answer = ref<string | null>(null)
-
-const { t } = useI18n()
-
-onMounted(async () => {
-  try {
-    if (props.step.type === 'coding' && props.step.coding?.path) {
-      answer.value = await extractAnswerFromFile(props.step.coding.path)
-    }
-  } catch (error) {
-    console.error('初始化answer时出错:', error)
-    // 提供一个备用答案以确保测试可以继续
-    answer.value = '// 无法加载答案，请检查控制台错误'
-  }
-
-  try {
-    if (props.step.snapshot?.startSnapshot) {
-      await loadSnapshot(props.step.snapshot.startSnapshot)
-      console.log('Snapshot 加载完成')
-    } else {
-      console.log('未提供开始快照')
-    }
-  } catch (error) {
-    console.error('快照加载出错：', error)
-  }
-
-  // 设置过滤器
-  if (props.step.isApiControl) {
-    filter.setFilter('apiReference', true, props.step.apis)
-  }
-  if (props.step.isAssetControl) {
-    filter.setFilter('asset', true, props.step.assets)
-  }
-  if (props.step.isSpriteControl) {
-    filter.setFilter('sprite', true, props.step.sprites)
-  }
-  if (props.step.isSoundControl) {
-    filter.setFilter('sound', true, props.step.sounds)
-  }
-  if (props.step.isCostumeControl) {
-    filter.setFilter('costume', true, props.step.costumes)
-  }
-  if (props.step.isAnimationControl) {
-    filter.setFilter('animation', true, props.step.animations)
-  }
-  if (props.step.isWidgetControl) {
-    filter.setFilter('widget', true, props.step.widgets)
-  }
-  if (props.step.isBackdropControl) {
-    filter.setFilter('backdrop', true, props.step.backdrops)
-  }
-  currentGuidePositions = null
-
-  // 延迟设置目标元素监听器
-  setTimeout(() => {
-    setupTargetElementListener()
-  }, 100)
-})
-
-onBeforeUnmount(() => {
-  filter.reset()
-})
-
-async function loadSnapshot(snapshotStr: string): Promise<void> {
-  if (!snapshotStr) {
-    console.log('快照字符串为空，直接返回')
-    return Promise.reject(new Error('Snapshot string is empty'))
-  }
-
-  try {
-    const project = editorCtx.project
-    if (!project) return Promise.reject(new Error('Project instance not available'))
-
-    let fileCollection: FileCollection
-    try {
-      fileCollection = JSON.parse(snapshotStr)
-    } catch (parseError) {
-      console.error('解析快照 JSON 失败:', parseError)
-      return
-    }
-
-    const files = getFiles(fileCollection)
-    console.log(`从快照中加载了 ${Object.keys(files).length} 个文件`)
-
-    // 验证JSON文件
-    await validateJsonFiles(files)
-
-    // 加载有效的文件
-    if (Object.keys(files).length > 0) {
-      try {
-        await project.loadGameFiles(files)
-        console.log('成功加载游戏文件')
-      } catch (loadError) {
-        console.error('加载游戏文件失败:', loadError)
-      }
-    }
-  } catch (error) {
-    console.error('加载快照失败:', error)
-  }
-}
-
-async function validateJsonFiles(files: Files): Promise<void> {
-  const jsonFiles = Object.keys(files).filter((path) => path.endsWith('.json'))
-  for (const jsonPath of jsonFiles) {
-    try {
-      const file = files[jsonPath]
-      if (file) {
-        const content = await toText(file)
-        JSON.parse(content)
-      }
-    } catch (e) {
-      console.warn(`移除无效 JSON: ${jsonPath}`)
-      delete files[jsonPath]
-    }
-  }
-}
-
-async function getSnapshot(): Promise<string> {
-  const project = editorCtx.project
-  const files = await project.exportGameFiles()
-  return JSON.stringify({ files })
-}
-
-async function extractAnswerFromFile(path: string): Promise<string | null> {
-  try {
-    if (!path || path.trim().length === 0) {
-      console.warn('路径为空，尝试使用备用答案')
-      return getMockAnswer()
-    }
-
-    console.log('提取答案，路径:', path)
-
-    const project = editorCtx.project
-    if (!project) {
-      console.error('无法获取项目实例')
-      return getMockAnswer()
-    }
-
-    const files = await project.exportGameFiles()
-    if (!files || !files[path]) {
-      console.error('找不到文件:', path)
-      return getMockAnswer()
-    }
-
-    const file = files[path]
-    if (!file) {
-      console.error('文件对象为空')
-      return getMockAnswer()
-    }
-
-    const fileContent = await toText(file)
-
-    console.log('文件内容大小:', fileContent ? fileContent.length : 0)
-
-    if (!fileContent || fileContent.length < 10) {
-      console.error('文件内容过短或为空')
-      return getMockAnswer()
-    }
-
-    const startPos = props.step.coding?.startPosition
-    const endPos = props.step.coding?.endPosition
-
-    if (!startPos || !endPos) {
-      console.error('缺少位置信息')
-      return null
-    }
-
-    const lines = fileContent.split('\n')
-    console.log('文件行数:', lines.length)
-
-    const adjustedStartLine = Math.min(startPos.line, lines.length)
-    const adjustedEndLine = Math.min(endPos.line, lines.length)
-
-    console.log('调整后的位置:', {
-      original: { start: startPos.line, end: endPos.line },
-      adjusted: { start: adjustedStartLine, end: adjustedEndLine }
-    })
-
-    let extractedContent = ''
-
-    if (adjustedStartLine === adjustedEndLine) {
-      const line = lines[adjustedStartLine - 1]
-      const startCol = Math.min(startPos.column - 1, line.length)
-      const endCol = Math.min(endPos.column - 1, line.length)
-      extractedContent = line.substring(startCol, endCol)
-    } else {
-      const firstLine = lines[adjustedStartLine - 1]
-      const firstLineIndent = firstLine.match(/^\s*/)?.at(0) || ''
-      const startCol = Math.min(startPos.column - 1, firstLine.length)
-      extractedContent += firstLineIndent + firstLine.substring(startCol) + '\n'
-
-      for (let i = adjustedStartLine; i < adjustedEndLine - 1 && i < lines.length; i++) {
-        extractedContent += lines[i] + '\n'
-      }
-
-      if (adjustedEndLine <= lines.length) {
-        const lastLine = lines[adjustedEndLine - 1]
-        const endCol = Math.min(endPos.column - 1, lastLine.length)
-        extractedContent += lastLine.substring(0, endCol)
-      }
-    }
-
-    return extractedContent || getMockAnswer()
-  } catch (error) {
-    console.error('提取答案时出错:', error)
-    return getMockAnswer()
-  }
-}
-
-function getMockAnswer(): string {
-  return `// 这是一个模拟的参考答案
-onStart => {
-  think "如何通过斑马线呢？", 4
-  broadcast "start"
-}
-
-onTouchStart => {
-  die
-}
-
-onKey KeyDown, => {
-  setHeading 180
-  step 30
-}`
-}
-
-function calculateBubbleArrowDirection(niuxiaoqiPos: any, bubblePos: any) {
-  // 计算气泡中心点
-  const bubbleCenter = {
-    x: bubblePos.left + bubblePos.width / 2,
-    y: bubblePos.top + bubblePos.height / 2
-  }
-
-  // 计算牛小七中心点
-  const niuxiaoqiCenter = {
-    x: niuxiaoqiPos.left + niuxiaoqiPos.width / 2,
-    y: niuxiaoqiPos.top + niuxiaoqiPos.height / 2
-  }
-
-  // 确定箭头方向 (top、right、bottom、left)
-  const dx = bubbleCenter.x - niuxiaoqiCenter.x
-  const dy = bubbleCenter.y - niuxiaoqiCenter.y
-
-  // 主导方向决定箭头在哪个边
-  if (Math.abs(dx) > Math.abs(dy)) {
-    // 水平方向为主
-    return dx > 0 ? 'left' : 'right'
-  } else {
-    // 垂直方向为主
-    return dy > 0 ? 'top' : 'bottom'
-  }
-}
-
-function calculateGuidePositions(highlightRect: HighlightRect) {
-  const windowWidth = window.innerWidth
-  const windowHeight = window.innerHeight
-
-  // 确定高亮区域所在象限
-  const isLeft = highlightRect.left + highlightRect.width / 2 < windowWidth / 2
-  const isTop = highlightRect.top + highlightRect.height / 2 < windowHeight / 2
-
-  const scale = Math.min(windowWidth, windowHeight) / 1920
-  const maxScale = 0.8 // 限制最大缩放比例
-  const finalScale = Math.min(scale, maxScale)
-
-  const arrowSize = {
-    width: 200 * finalScale,
-    height: 200 * finalScale
-  }
-  const niuxiaoqiSize = {
-    width: 300 * finalScale,
-    height: 320 * finalScale
-  }
-  const bubbleSize = {
-    width: 300 * finalScale,
-    height: 200 * finalScale
-  }
-
-  let arrowPosition = { left: 0, top: 0 }
-  let niuxiaoqiPosition = { left: 0, top: 0 }
-  let bubblePosition = { left: 0, top: 0 }
-
-  let arrowRotation = 0
-
-  // 根据象限计算位置
-  if (isLeft && isTop) {
-    // 左上象限
-    arrowPosition = {
-      left: highlightRect.left + highlightRect.width,
-      top: highlightRect.top + highlightRect.height
-    }
-    arrowRotation = 270
-    niuxiaoqiPosition = {
-      left: arrowPosition.left + arrowSize.width,
-      top: arrowPosition.top + arrowSize.height
-    }
-    bubblePosition = {
-      left: niuxiaoqiPosition.left + niuxiaoqiSize.width,
-      top: niuxiaoqiPosition.top - bubbleSize.height
-    }
-  } else if (!isLeft && isTop) {
-    // 右上象限
-    arrowPosition = {
-      left: highlightRect.left - arrowSize.width,
-      top: highlightRect.top + highlightRect.height
-    }
-    arrowRotation = 0
-    niuxiaoqiPosition = {
-      left: arrowPosition.left - niuxiaoqiSize.width,
-      top: arrowPosition.top + arrowSize.height
-    }
-    bubblePosition = {
-      left: niuxiaoqiPosition.left - bubbleSize.width,
-      top: niuxiaoqiPosition.top - bubbleSize.height
-    }
-  } else if (isLeft && !isTop) {
-    // 左下象限
-    arrowPosition = {
-      left: highlightRect.left + highlightRect.width,
-      top: highlightRect.top - arrowSize.height
-    }
-    arrowRotation = 180
-    niuxiaoqiPosition = {
-      left: arrowPosition.left + arrowSize.width,
-      top: arrowPosition.top - niuxiaoqiSize.height
-    }
-    bubblePosition = {
-      left: niuxiaoqiPosition.left + niuxiaoqiSize.width,
-      top: niuxiaoqiPosition.top + niuxiaoqiSize.height
-    }
-  } else {
-    // 右下象限
-    arrowPosition = {
-      left: highlightRect.left - arrowSize.width,
-      top: highlightRect.top - arrowSize.height
-    }
-    arrowRotation = 90
-    niuxiaoqiPosition = {
-      left: arrowPosition.left - niuxiaoqiSize.width,
-      top: arrowPosition.top - niuxiaoqiSize.height
-    }
-    bubblePosition = {
-      left: niuxiaoqiPosition.left - bubbleSize.width,
-      top: niuxiaoqiPosition.top + niuxiaoqiSize.height
-    }
-  }
-
-  const bubbleArrowDirection = calculateBubbleArrowDirection(
-    {
-      left: niuxiaoqiPosition.left,
-      top: niuxiaoqiPosition.top,
-      width: niuxiaoqiSize.width,
-      height: niuxiaoqiSize.height
-    },
-    { left: bubblePosition.left, top: bubblePosition.top, width: bubbleSize.width, height: bubbleSize.height }
-  )
-
-  return {
-    arrowStyle: {
-      position: 'absolute',
-      left: `${arrowPosition.left}px`,
-      top: `${arrowPosition.top}px`,
-      width: `${arrowSize.width}px`,
-      height: `${arrowSize.height}px`,
-      transform: `rotate(${arrowRotation}deg)`,
-      transformOrigin: 'center center'
-    },
-    niuxiaoqiStyle: {
-      position: 'absolute',
-      left: `${niuxiaoqiPosition.left}px`,
-      top: `${niuxiaoqiPosition.top}px`,
-      width: `${niuxiaoqiSize.width}px`,
-      height: `${niuxiaoqiSize.height}px`
-    },
-    bubbleStyle: {
-      position: 'absolute',
-      left: `${bubblePosition.left}px`,
-      top: `${bubblePosition.top}px`,
-      width: `${bubbleSize.width}px`,
-      height: `${bubbleSize.height}px`,
-      arrowDirection: bubbleArrowDirection
-    }
-  }
-}
-
-let currentGuidePositions: { arrowStyle: any; niuxiaoqiStyle: any; bubbleStyle: any } | null = null
-
-function getArrowStyle(highlightRect: HighlightRect) {
-  if (!currentGuidePositions) {
-    currentGuidePositions = calculateGuidePositions(highlightRect)
-  }
-  return currentGuidePositions.arrowStyle
-}
-
-function getNiuxiaoqiStyle(highlightRect: HighlightRect) {
-  if (!currentGuidePositions) {
-    currentGuidePositions = calculateGuidePositions(highlightRect)
-  }
-  return currentGuidePositions.niuxiaoqiStyle
-}
-
-function getBubbleContainerStyle(highlightRect: HighlightRect) {
-  if (!currentGuidePositions) {
-    currentGuidePositions = calculateGuidePositions(highlightRect)
-  }
-
-  const { left, top, width, height } = currentGuidePositions.bubbleStyle
-
-  return {
-    position: 'absolute' as const,
-    left,
-    top,
-    width,
-    height
-  }
-}
-
-function getBubbleBgStyle(highlightRect: HighlightRect) {
-  if (!currentGuidePositions) {
-    currentGuidePositions = calculateGuidePositions(highlightRect)
-  }
-
-  const arrowDirection = currentGuidePositions.bubbleStyle.arrowDirection
-
-  let transform = ''
-
-  switch (arrowDirection) {
-    case 'bottom':
-      transform = ''
-      break
-    case 'top':
-      transform = 'scale(1, -1)'
-      break
-    case 'left':
-      transform = 'scale(-1, 1)'
-      break
-    case 'right':
-      transform = 'scale(-1, -1)'
-      break
-  }
-
-  return {
-    transform,
-    transformOrigin: 'center center',
-    width: '100%',
-    height: '100%'
-  }
-}
-
-// 修改setupTargetElementListener函数，使用onMounted + setTimeout确保安全调用
-function setupTargetElementListener() {
-  if (props.step.type !== 'following' || !props.step.target) return
-
-  // 使用 nextTick 确保组件已完全挂载
-  nextTick(() => {
-    try {
-      const { getElement } = useTag()
-      const element = getElement(props.step.target)
-      if (element) {
-        console.log('Target element found, adding click listener')
-        element.addEventListener('click', handleTargetElementClick)
-      } else {
-        console.warn('Target element not found:', props.step.target)
-      }
-    } catch (error) {
-      console.warn('Error setting up target element listener:', error)
-    }
-  })
-}
-
-async function handleTargetElementClick() {
-  if (props.step.type !== 'following') return
-
-  if (props.step.snapshot?.endSnapshot && props.step.isCheck) {
-    const result = await compareSnapshot(props.step.snapshot.endSnapshot)
-    if (result.success) {
-      emit('stepCompleted')
-    } else {
-      console.warn('Snapshot comparison failed:', result.reason)
-    }
-  }
-}
-
-function handleCheckButtonClick() {
-  if (props.step.type !== 'coding') return
-
-  isCheckingDialogVisible.value = true
-
-  if (props.step.isCheck) {
-    checkAnswer().then((result) => {
-      if (result) {
-        isCheckingDialogVisible.value = false
-        isNextDialogVisible.value = true
-      } else {
-        isCheckingDialogVisible.value = false
-        isRetryDialogVisible.value = true
-        console.warn('Answer mismatch')
-      }
-    })
-  }
-}
-
-function handleInfoButtonClick() {
-  if (props.step.type !== 'coding') return
-
-  isInfoDialogVisible.value = !isInfoDialogVisible.value
-}
-
-function handleAnswerButtonClick() {
-  if (props.step.type !== 'coding') return
-
-  isAnswerDialogVisible.value = !isAnswerDialogVisible.value
-}
-
-function handleNextButtonClick() {
-  isNextDialogVisible.value = false
-  emit('stepCompleted')
-}
-
-function handleRetryButtonClick() {
-  isRetryDialogVisible.value = false
-}
-
-function handleAnswerFromTimeout() {
-  isTimeoutDialogVisible.value = false
-  isAnswerDialogVisible.value = true
-}
-
-function handleInfoCloseButtonClick() {
-  isInfoDialogVisible.value = false
-}
-
-function handleAnswerCloseButtonClick() {
-  isAnswerDialogVisible.value = false
-}
-
-async function checkAnswer(): Promise<boolean> {
-  const project = editorCtx.project
-  const files = await project.exportGameFiles()
-
-  if (!props.step.coding?.path) {
-    return false
-  }
-
-  const answer = await extractAnswerFromFile(props.step.coding.path)
-
-  if (!answer) {
-    return false
-  }
-
-  const userFile = files[props.step.coding.path]
-
-  if (!userFile) {
-    return false
-  }
-
-  const userAnswer = await extractAnswerFromFile(await toText(userFile))
-
-  return JSON.stringify(userAnswer) === JSON.stringify(answer)
-}
-
-async function compareSnapshot(snapshotStr: string): Promise<{ success: boolean; reason?: string }> {
-  if (!snapshotStr) return Promise.resolve({ success: false, reason: 'No end snapshot' })
-  const userSnapshot = await getSnapshot()
-  if (snapshotStr !== userSnapshot) {
-    return Promise.resolve({ success: false, reason: 'Snapshot mismatch' })
-  }
-  return Promise.resolve({ success: true })
-}
-</script>
-
-<style scoped>
-.step-player {
-  width: 100%;
-  height: 100%;
-  position: relative;
-  z-index: 1001;
-}
-
-.code-button-container {
-  position: fixed;
-  right: 20px;
-  top: 80px;
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  z-index: 10000;
-}
-
-.guidance-button {
-  padding: 12px 20px;
-  border: none;
-  border-radius: 4px;
-  font-weight: bold;
-  cursor: pointer;
-  transition: all 0.2s ease;
-  font-size: 16px;
-  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
-}
-
-.check-button {
-  background-color: #4caf50;
-  color: white;
-}
-
-.info-button {
-  background-color: #2196f3;
-  color: white;
-}
-
-.answer-button {
-  background-color: #ffc107;
-  color: black;
-}
-
-.guide-ui-container {
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  pointer-events: none;
-  z-index: 10000;
-}
-
-.guide-ui-container * {
-  pointer-events: auto;
-}
-
-.svg-fill {
-  fill: #ffeb3b;
-  opacity: 0.8;
-}
-
-.icon-fill {
-  fill: #5c5c66;
-}
-
-.suggestion-box {
-  z-index: 10001;
-}
-
-.bubble-container {
-  position: absolute;
-  pointer-events: none;
-  z-index: 10000;
-}
-
-.ic-bubble-bg {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-}
-
-.bubble-tip {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  padding: 10px;
-  box-sizing: border-box;
-  pointer-events: none;
-  margin-top: 0px;
-  font-size: 10px;
-  color: #666;
-  font-style: italic;
-  text-align: center;
-  border-top: 1px solid rgba(0, 0, 0, 0.1);
-  padding-top: 8px;
-  max-width: 100%;
-}
-</style>
diff --git a/spx-gui/src/components/guidance/step/CodingStep.vue b/spx-gui/src/components/guidance/step/CodingStep.vue
new file mode 100644
index 000000000..5be573207
--- /dev/null
+++ b/spx-gui/src/components/guidance/step/CodingStep.vue
@@ -0,0 +1,296 @@
+<template>
+  <div class="coding-step">
+    <div class="code-button-container">
+      <UIButton type="success" size="medium" @click="handleCheckButtonClick">
+        {{ t({ zh: '检查', en: 'Check' }) }}
+      </UIButton>
+      <UIButton type="primary" size="medium" @click="handleInfoButtonClick">
+        {{ t({ zh: '信息', en: 'Info' }) }}
+      </UIButton>
+      <UIButton type="secondary" size="medium" @click="handleAnswerButtonClick">
+        {{ t({ zh: '答案', en: 'Answer' }) }}
+      </UIButton>
+    </div>
+    <div class="suggestion-box">
+      <ResultDialog
+        :visible="isCheckingDialogVisible"
+        :title="t({ zh: '代码检查中', en: 'Checking code' })"
+        :content="''"
+        :loading="true"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isNextDialogVisible"
+        :title="t({ zh: '检测结果', en: 'Check Result' })"
+        :content="t({ zh: '太棒了！你的代码检测通过！', en: 'Great! Your code check passed!' })"
+        :button="t({ zh: '下一步', en: 'Next' })"
+        :button-action="'next'"
+        @next="handleNextButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isRetryDialogVisible"
+        :title="t({ zh: '检测结果', en: 'Check Result' })"
+        :content="t({ zh: '错误\n', en: 'Error\n' }) + props.step.tip.zh"
+        :button="t({ zh: '重试', en: 'Retry' })"
+        :button-action="'retry'"
+        @retry="handleRetryButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isAnswerDialogVisible"
+        :title="t({ zh: '参考答案', en: 'Reference Answer' })"
+        :content="''"
+        :is-code="true"
+        :button="t({ zh: '关闭', en: 'Close' })"
+        :button-action="'close'"
+        :code="answer || ''"
+        @close="handleAnswerCloseButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isInfoDialogVisible"
+        :title="t({ zh: '当前步骤', en: 'Current Step' })"
+        :content="t({ zh: props.step.description.zh, en: props.step.description.en })"
+        :button="t({ zh: '关闭', en: 'Close' })"
+        :button-action="'close'"
+        @close="handleInfoCloseButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isTimeoutDialogVisible"
+        :title="t({ zh: '温馨提醒', en: 'Friendly Reminder' })"
+        :content="
+          t({
+            zh: '牛小七发现你卡顿好久了...可以试着点击下方按钮直接查看答案哦～',
+            en: 'Niuxiaoqi found you stuck for a long time... You can click the button below to view the answer directly~'
+          })
+        "
+        :button="t({ zh: '查看答案', en: 'View Answer' })"
+        :button-action="'answer'"
+        @answer="handleAnswerFromTimeout"
+      >
+      </ResultDialog>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
+import UIButton from '../../ui/UIButton.vue'
+import ResultDialog from './ResultDialog.vue'
+import type { Step } from '@/apis/guidance'
+import { urlSafeBase64Decode } from 'qiniu-js'
+import { toText } from '@/models/common/file'
+import { useI18n } from '@/utils/i18n'
+
+const editorCtx = useEditorCtx()
+
+const props = defineProps<{
+  step: Step
+}>()
+
+const emit = defineEmits<{
+  codingStepCompleted: []
+}>()
+
+const { t } = useI18n()
+
+const isCheckingDialogVisible = ref(false)
+const isNextDialogVisible = ref(false)
+const isRetryDialogVisible = ref(false)
+const isAnswerDialogVisible = ref(false)
+const isInfoDialogVisible = ref(false)
+const isTimeoutDialogVisible = ref(false)
+
+const answer = ref<string | null>(null)
+let timeoutTimer: number | null = null
+
+onMounted(async () => {
+  try {
+    if (props.step.coding?.path) {
+      answer.value = await extractAnswerFromFile(props.step.coding.path)
+
+      if (!answer.value) {
+        answer.value = getMockAnswer()
+      }
+    }
+  } catch (error) {
+    console.error('初始化answer时出错:', error)
+    answer.value = '// 无法加载答案，请检查控制台错误'
+  }
+
+  //   if (props.step.duration > 0) {
+  //     timeoutTimer = window.setTimeout(() => {
+  //       isTimeoutDialogVisible.value = true
+  //     }, props.step.duration * 1000)
+  //   }
+})
+
+onBeforeUnmount(() => {
+  if (timeoutTimer) {
+    clearTimeout(timeoutTimer)
+  }
+})
+
+async function extractAnswerFromFile(path: string): Promise<string | null> {
+  try {
+    if (!path || path.length < 20) {
+      return getMockAnswer()
+    }
+
+    let fileContent: string
+    try {
+      fileContent = urlSafeBase64Decode(path)
+    } catch (err) {
+      console.error('Base64解码失败:', err)
+      return getMockAnswer()
+    }
+
+    if (!fileContent || fileContent.length < 10) {
+      return getMockAnswer()
+    }
+
+    const startPos = props.step.coding?.startPosition
+    const endPos = props.step.coding?.endPosition
+
+    if (!startPos || !endPos) {
+      return null
+    }
+
+    const lines = fileContent.split('\n')
+
+    const adjustedStartLine = Math.min(startPos.line, lines.length)
+    const adjustedEndLine = Math.min(endPos.line, lines.length)
+
+    let extractedContent = ''
+
+    if (adjustedStartLine === adjustedEndLine) {
+      const line = lines[adjustedStartLine - 1]
+      const startCol = Math.min(startPos.column - 1, line.length)
+      const endCol = Math.min(endPos.column - 1, line.length)
+      extractedContent = line.substring(startCol, endCol)
+    } else {
+      const firstLine = lines[adjustedStartLine - 1]
+      const firstLineIndent = firstLine.match(/^\s*/)?.at(0) || ''
+      const startCol = Math.min(startPos.column - 1, firstLine.length)
+      extractedContent += firstLineIndent + firstLine.substring(startCol) + '\n'
+
+      for (let i = adjustedStartLine; i < adjustedEndLine - 1 && i < lines.length; i++) {
+        extractedContent += lines[i] + '\n'
+      }
+
+      if (adjustedEndLine <= lines.length) {
+        const lastLine = lines[adjustedEndLine - 1]
+        const endCol = Math.min(endPos.column - 1, lastLine.length)
+        extractedContent += lastLine.substring(0, endCol)
+      }
+    }
+
+    return extractedContent || getMockAnswer()
+  } catch (error) {
+    console.error('提取答案时出错:', error)
+    return getMockAnswer()
+  }
+}
+
+function getMockAnswer(): string {
+  return `// 这是一个模拟的参考答案
+onStart => {
+  think "如何通过斑马线呢？", 4
+  broadcast "start"
+}
+
+onTouchStart => {
+  die
+}
+
+onKey KeyDown, => {
+  setHeading 180
+  step 30
+}`
+}
+
+function handleCheckButtonClick() {
+  isCheckingDialogVisible.value = true
+
+  if (props.step.isCheck) {
+    checkAnswer().then((result) => {
+      if (result) {
+        isCheckingDialogVisible.value = false
+        isNextDialogVisible.value = true
+      } else {
+        isCheckingDialogVisible.value = false
+        isRetryDialogVisible.value = true
+      }
+    })
+  }
+}
+
+function handleInfoButtonClick() {
+  isInfoDialogVisible.value = !isInfoDialogVisible.value
+}
+
+function handleAnswerButtonClick() {
+  isAnswerDialogVisible.value = !isAnswerDialogVisible.value
+}
+
+function handleNextButtonClick() {
+  isNextDialogVisible.value = false
+  emit('codingStepCompleted')
+}
+
+function handleRetryButtonClick() {
+  isRetryDialogVisible.value = false
+}
+
+function handleAnswerFromTimeout() {
+  isTimeoutDialogVisible.value = false
+  isAnswerDialogVisible.value = true
+}
+
+function handleInfoCloseButtonClick() {
+  isInfoDialogVisible.value = false
+}
+
+function handleAnswerCloseButtonClick() {
+  isAnswerDialogVisible.value = false
+}
+async function checkAnswer(): Promise<boolean> {
+  const project = editorCtx.project
+  const files = await project.exportGameFiles()
+
+  if (!props.step.coding?.path) {
+    return false
+  }
+
+  const extractedAnswer = await extractAnswerFromFile(props.step.coding?.path)
+
+  if (!extractedAnswer) {
+    return false
+  }
+
+  const userFile = files[props.step.coding.path]
+
+  if (!userFile) {
+    return false
+  }
+
+  const userAnswer = await extractAnswerFromFile(await toText(userFile))
+
+  return userAnswer === extractedAnswer
+}
+
+async function getSnapshot(): Promise<string> {
+  const project = editorCtx.project
+  const files = await project.exportGameFiles()
+  return JSON.stringify({ files })
+}
+</script>
+
+<style scoped>
+.suggestion-box {
+  z-index: 10001;
+}
+</style>
diff --git a/spx-gui/src/components/guidance/step/FollowingStep.vue b/spx-gui/src/components/guidance/step/FollowingStep.vue
new file mode 100644
index 000000000..03d23bee5
--- /dev/null
+++ b/spx-gui/src/components/guidance/step/FollowingStep.vue
@@ -0,0 +1,424 @@
+<template>
+  <div class="guide-ui-container">
+    <svg
+      class="ic-arrow"
+      :style="getArrowStyle(props.slotInfo)"
+      viewBox="0 0 1024 1024"
+      version="1.1"
+      xmlns="http://www.w3.org/2000/svg"
+      p-id="1444"
+      data-spm-anchor-id="a313x.search_index.0.i0.6e8f3a81bBVDWW"
+      width="300"
+      height="200"
+    >
+      <path
+        d="M149.333333 896a21.333333 21.333333 0 0 1-15.086666-36.42L823.166667 170.666667H661.333333a21.333333 21.333333 0 0 1 0-42.666667h213.46a21.333333 21.333333 0 0 1 21.206667 21.206667V362.666667a21.333333 21.333333 0 0 1-42.666667 0V200.833333L164.42 889.753333A21.266667 21.266667 0 0 1 149.333333 896z"
+        fill="#5C5C66"
+        class="icon-fill"
+        p-id="1445"
+      ></path>
+    </svg>
+    <img
+      class="niuxiaoqi"
+      :style="getNiuxiaoqiStyle(props.slotInfo)"
+      width="300"
+      height="320"
+      src="https://www-static.qbox.me/sem/pili-live-1001/source/img/qiniu.png"
+    />
+    <div class="bubble-container" :style="getBubbleContainerStyle(slotInfo)">
+      <svg
+        class="ic-bubble-bg"
+        :style="getBubbleBgStyle(props.slotInfo)"
+        viewBox="0 0 1536 1024"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <path
+          d="M269.824 1024H0l122.481778-133.461333V56.888889a56.888889 56.888889 0 0 1 56.888889-56.888889H1479.111111a56.888889 56.888889 0 0 1 56.888889 56.888889v910.222222a56.888889 56.888889 0 0 1-56.888889 56.888889z"
+          class="svg-fill"
+        ></path>
+      </svg>
+      <div class="bubble-content">
+        {{ t({ zh: props.step.description.zh, en: props.step.description.en }) }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount, nextTick } from 'vue'
+import { useTag } from '@/utils/tagging'
+import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
+import type { Step } from '@/apis/guidance'
+import type { HighlightRect } from '@/components/common/MaskWithHighlight.vue'
+import { useI18n } from '@/utils/i18n'
+
+const editorCtx = useEditorCtx()
+
+const props = defineProps<{
+  step: Step
+  slotInfo: HighlightRect
+}>()
+
+const emit = defineEmits<{
+  followingStepCompleted: []
+}>()
+
+const { t } = useI18n()
+
+let currentGuidePositions: {
+  arrowStyle: any
+  niuxiaoqiStyle: any
+  bubbleStyle: any
+} | null = null
+
+// 设置事件键盘监听器，禁用escape和enter键
+function setupKeyboardEventListeners() {
+  document.addEventListener('keydown', preventEscapeAndEnter)
+}
+
+// 防止按下escape和enter键
+function preventEscapeAndEnter(event: KeyboardEvent) {
+  if (event.key === 'Escape' || event.key === 'Enter') {
+    event.preventDefault()
+    event.stopPropagation()
+  }
+}
+
+onMounted(() => {
+  setupTargetElementListener()
+  setupKeyboardEventListeners()
+
+  nextTick(() => {
+    currentGuidePositions = calculateGuidePositions(props.slotInfo)
+  })
+})
+
+onBeforeUnmount(() => {
+  const element = getTargetElement()
+  if (element) {
+    element.removeEventListener('click', handleTargetElementClick)
+  }
+
+  document.removeEventListener('keydown', preventEscapeAndEnter)
+})
+
+function getTargetElement(): HTMLElement | null {
+  if (!props.step.target) return null
+
+  try {
+    const { getElement } = useTag()
+    return getElement(props.step.target)
+  } catch (error) {
+    console.warn('获取目标元素失败:', error)
+    return null
+  }
+}
+
+function setupTargetElementListener() {
+  if (props.step.type !== 'following' || !props.step.target) return
+
+  nextTick(() => {
+    try {
+      const { getElement } = useTag()
+      const element = getElement(props.step.target)
+      if (element) {
+        console.log('Setting up target element listener:', element)
+        element.addEventListener('click', handleTargetElementClick)
+      } else {
+        console.warn('Target element not found:', props.step.target)
+      }
+    } catch (error) {
+      console.warn('Error setting up target element listener:', error)
+    }
+  })
+}
+
+async function handleTargetElementClick() {
+  // 如果有结束快照并且需要检查
+  if (props.step.snapshot?.endSnapshot && props.step.isCheck) {
+    const result = await compareSnapshot(props.step.snapshot.endSnapshot)
+    if (result.success) {
+      emit('followingStepCompleted')
+    } else {
+      console.warn('快照比较失败:', result.reason)
+    }
+  } else {
+    // 没有结束快照要求，直接完成步骤
+    emit('followingStepCompleted')
+  }
+}
+
+async function compareSnapshot(snapshotStr: string): Promise<{ success: boolean; reason?: string }> {
+  if (!snapshotStr) return { success: false, reason: '没有结束快照' }
+
+  const userSnapshot = await getSnapshot()
+  if (snapshotStr !== userSnapshot) {
+    return { success: false, reason: '快照不匹配' }
+  }
+  return { success: true }
+}
+
+async function getSnapshot(): Promise<string> {
+  const project = editorCtx.project
+  const files = await project.exportGameFiles()
+  return JSON.stringify({ files })
+}
+
+function calculateBubbleArrowDirection(niuxiaoqiPos: HighlightRect, bubblePos: HighlightRect) {
+  const niuxiaoqiCenter = {
+    x: niuxiaoqiPos.left + niuxiaoqiPos.width / 2,
+    y: niuxiaoqiPos.top + niuxiaoqiPos.height / 2
+  }
+
+  const bubbleCenter = {
+    x: bubblePos.left + bubblePos.width / 2,
+    y: bubblePos.top + bubblePos.height / 2
+  }
+
+  const dx = bubbleCenter.x - niuxiaoqiCenter.x
+  const dy = bubbleCenter.y - niuxiaoqiCenter.y
+
+  if (Math.abs(dx) > Math.abs(dy)) {
+    return dx > 0 ? 'left' : 'right'
+  } else {
+    return dy > 0 ? 'top' : 'bottom'
+  }
+}
+
+function calculateGuidePositions(highlightRect: HighlightRect) {
+  const windowWidth = window.innerWidth
+  const windowHeight = window.innerHeight
+
+  // 确定高亮区域所在象限
+  const isLeft = highlightRect.left + highlightRect.width / 2 < windowWidth / 2
+  const isTop = highlightRect.top + highlightRect.height / 2 < windowHeight / 2
+
+  const scale = Math.min(windowWidth, windowHeight) / 1920
+  const maxScale = 0.8 // 限制最大缩放比例
+  const finalScale = Math.min(scale, maxScale)
+
+  const arrowSize = {
+    width: 200 * finalScale,
+    height: 200 * finalScale
+  }
+  const niuxiaoqiSize = {
+    width: 300 * finalScale,
+    height: 320 * finalScale
+  }
+  const bubbleSize = {
+    width: 300 * finalScale,
+    height: 200 * finalScale
+  }
+
+  let arrowPosition = { left: 0, top: 0 }
+  let niuxiaoqiPosition = { left: 0, top: 0 }
+  let bubblePosition = { left: 0, top: 0 }
+
+  let arrowRotation = 0
+
+  // 根据象限计算位置
+  if (isLeft && isTop) {
+    // 左上象限
+    arrowPosition = {
+      left: highlightRect.left + highlightRect.width,
+      top: highlightRect.top + highlightRect.height
+    }
+    arrowRotation = 270
+    niuxiaoqiPosition = {
+      left: arrowPosition.left + arrowSize.width,
+      top: arrowPosition.top + arrowSize.height
+    }
+    bubblePosition = {
+      left: niuxiaoqiPosition.left + niuxiaoqiSize.width,
+      top: niuxiaoqiPosition.top - bubbleSize.height
+    }
+  } else if (!isLeft && isTop) {
+    // 右上象限
+    arrowPosition = {
+      left: highlightRect.left - arrowSize.width,
+      top: highlightRect.top + highlightRect.height
+    }
+    arrowRotation = 0
+    niuxiaoqiPosition = {
+      left: arrowPosition.left - niuxiaoqiSize.width,
+      top: arrowPosition.top + arrowSize.height
+    }
+    bubblePosition = {
+      left: niuxiaoqiPosition.left - bubbleSize.width,
+      top: niuxiaoqiPosition.top - bubbleSize.height
+    }
+  } else if (isLeft && !isTop) {
+    // 左下象限
+    arrowPosition = {
+      left: highlightRect.left + highlightRect.width,
+      top: highlightRect.top - arrowSize.height
+    }
+    arrowRotation = 180
+    niuxiaoqiPosition = {
+      left: arrowPosition.left + arrowSize.width,
+      top: arrowPosition.top - niuxiaoqiSize.height
+    }
+    bubblePosition = {
+      left: niuxiaoqiPosition.left + niuxiaoqiSize.width,
+      top: niuxiaoqiPosition.top + niuxiaoqiSize.height
+    }
+  } else {
+    // 右下象限
+    arrowPosition = {
+      left: highlightRect.left - arrowSize.width,
+      top: highlightRect.top - arrowSize.height
+    }
+    arrowRotation = 90
+    niuxiaoqiPosition = {
+      left: arrowPosition.left - niuxiaoqiSize.width,
+      top: arrowPosition.top - niuxiaoqiSize.height
+    }
+    bubblePosition = {
+      left: niuxiaoqiPosition.left - bubbleSize.width,
+      top: niuxiaoqiPosition.top + niuxiaoqiSize.height
+    }
+  }
+
+  const bubbleArrowDirection = calculateBubbleArrowDirection(
+    {
+      left: niuxiaoqiPosition.left,
+      top: niuxiaoqiPosition.top,
+      width: niuxiaoqiSize.width,
+      height: niuxiaoqiSize.height
+    },
+    { left: bubblePosition.left, top: bubblePosition.top, width: bubbleSize.width, height: bubbleSize.height }
+  )
+
+  return {
+    arrowStyle: {
+      position: 'absolute',
+      left: `${arrowPosition.left}px`,
+      top: `${arrowPosition.top}px`,
+      width: `${arrowSize.width}px`,
+      height: `${arrowSize.height}px`,
+      transform: `rotate(${arrowRotation}deg)`,
+      transformOrigin: 'center center'
+    },
+    niuxiaoqiStyle: {
+      position: 'absolute',
+      left: `${niuxiaoqiPosition.left}px`,
+      top: `${niuxiaoqiPosition.top}px`,
+      width: `${niuxiaoqiSize.width}px`,
+      height: `${niuxiaoqiSize.height}px`
+    },
+    bubbleStyle: {
+      position: 'absolute',
+      left: `${bubblePosition.left}px`,
+      top: `${bubblePosition.top}px`,
+      width: `${bubbleSize.width}px`,
+      height: `${bubbleSize.height}px`,
+      arrowDirection: bubbleArrowDirection
+    }
+  }
+}
+
+function getArrowStyle(highlightRect: HighlightRect) {
+  if (!currentGuidePositions) {
+    currentGuidePositions = calculateGuidePositions(highlightRect)
+  }
+  return currentGuidePositions.arrowStyle
+}
+
+function getNiuxiaoqiStyle(highlightRect: HighlightRect) {
+  if (!currentGuidePositions) {
+    currentGuidePositions = calculateGuidePositions(highlightRect)
+  }
+  return currentGuidePositions.niuxiaoqiStyle
+}
+
+function getBubbleContainerStyle(highlightRect: HighlightRect) {
+  if (!currentGuidePositions) {
+    currentGuidePositions = calculateGuidePositions(highlightRect)
+  }
+
+  const { left, top, width, height } = currentGuidePositions.bubbleStyle
+
+  return {
+    position: 'absolute' as const,
+    left,
+    top,
+    width,
+    height
+  }
+}
+
+// 添加气泡背景样式函数
+function getBubbleBgStyle(highlightRect: HighlightRect) {
+  if (!currentGuidePositions) {
+    currentGuidePositions = calculateGuidePositions(highlightRect)
+  }
+
+  const arrowDirection = currentGuidePositions.bubbleStyle.arrowDirection
+
+  let transform = ''
+
+  switch (arrowDirection) {
+    case 'bottom':
+      transform = ''
+      break
+    case 'top':
+      transform = 'scale(1, -1)'
+      break
+    case 'left':
+      transform = 'scale(-1, 1)'
+      break
+    case 'right':
+      transform = 'scale(-1, -1)'
+      break
+  }
+
+  return {
+    transform,
+    transformOrigin: 'center center',
+    width: '100%',
+    height: '100%'
+  }
+}
+</script>
+
+<style scoped>
+.guide-ui-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  z-index: 10000;
+}
+
+.guide-ui-container * {
+  pointer-events: auto;
+}
+
+.svg-fill {
+  fill: #ffeb3b;
+  opacity: 0.8;
+}
+
+.icon-fill {
+  fill: #5c5c66;
+}
+
+.bubble-content {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 40px;
+  box-sizing: border-box;
+  font-size: 18px;
+  color: #333;
+  text-align: center;
+  pointer-events: none;
+}
+</style>
diff --git a/spx-gui/src/components/guidance/ResultDialog.vue b/spx-gui/src/components/guidance/step/ResultDialog.vue
similarity index 96%
rename from spx-gui/src/components/guidance/ResultDialog.vue
rename to spx-gui/src/components/guidance/step/ResultDialog.vue
index 2227e2bc3..d964e7584 100644
--- a/spx-gui/src/components/guidance/ResultDialog.vue
+++ b/spx-gui/src/components/guidance/step/ResultDialog.vue
@@ -24,8 +24,8 @@
 
 <script setup lang="ts">
 import { computed } from 'vue'
-import UIButton from '../ui/UIButton.vue'
-import UILoading from '../ui/UILoading.vue'
+import UIButton from '@/components/ui/UIButton.vue'
+import UILoading from '@/components/ui/UILoading.vue'
 
 const emit = defineEmits(['next', 'retry', 'answer', 'close'])
 
diff --git a/spx-gui/src/components/guidance/step/StepPlayer.vue b/spx-gui/src/components/guidance/step/StepPlayer.vue
new file mode 100644
index 000000000..036bd090c
--- /dev/null
+++ b/spx-gui/src/components/guidance/step/StepPlayer.vue
@@ -0,0 +1,84 @@
+<template>
+  <div class="step-player">
+    <MaskWithHighlight :visible="true" :highlight-element-path="props.step.target">
+      <template v-if="props.step.type === 'coding'">
+        <CodingStep :step="props.step" @coding-step-completed="handleStepCompleted" />
+      </template>
+      <template v-if="props.step.type === 'following'" #default="{ slotInfo }">
+        <FollowingStep :step="props.step" :slot-info="slotInfo" @following-step-completed="handleStepCompleted" />
+      </template>
+    </MaskWithHighlight>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount } from 'vue'
+import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
+import MaskWithHighlight from '@/components/common/MaskWithHighlight.vue'
+import type { Step } from '@/apis/guidance'
+import CodingStep from './CodingStep.vue'
+import FollowingStep from './FollowingStep.vue'
+
+const editorCtx = useEditorCtx()
+const filter = editorCtx.listFilter
+
+const props = defineProps<{
+  step: Step
+}>()
+
+const emit = defineEmits<{
+  stepCompleted: []
+}>()
+
+onMounted(async () => {
+  try {
+    if (props.step.snapshot?.startSnapshot) {
+      await loadSnapshot(props.step.snapshot.startSnapshot)
+    }
+  } catch (error) {
+    console.error('Failed to load snapshot:', error)
+  }
+
+  setFilterControls()
+})
+
+onBeforeUnmount(() => {
+  filter.reset()
+})
+
+async function loadSnapshot(snapshotStr: string): Promise<void> {
+  if (!snapshotStr) return
+
+  try {
+    const project = editorCtx.project
+    const { files } = JSON.parse(snapshotStr)
+    await project.loadGameFiles(files)
+  } catch (error) {
+    console.error('Failed to load snapshot:', error)
+  }
+}
+
+function setFilterControls() {
+  filter.setFilter('apiReference', props.step.isApiControl, props.step.apis)
+  filter.setFilter('asset', props.step.isAssetControl, props.step.assets)
+  filter.setFilter('sprite', props.step.isSpriteControl, props.step.sprites)
+  filter.setFilter('sound', props.step.isSoundControl, props.step.sounds)
+  filter.setFilter('costume', props.step.isCostumeControl, props.step.costumes)
+  filter.setFilter('animation', props.step.isAnimationControl, props.step.animations)
+  filter.setFilter('widget', props.step.isWidgetControl, props.step.widgets)
+  filter.setFilter('backdrop', props.step.isBackdropControl, props.step.backdrops)
+}
+
+function handleStepCompleted() {
+  emit('stepCompleted')
+}
+</script>
+
+<style scoped>
+.step-player {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  z-index: 1001;
+}
+</style>


Reviews:
user: login: nighca
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-19T07:05:05Z
user: login: neverbiasu
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-19T08:25:16Z

Review comments:
diff_hunk: @@ -0,0 +1,296 @@
+<template>
+  <div class="coding-step">
+    <div class="code-button-container">
+      <UIButton type="success" size="medium" @click="handleCheckButtonClick">
+        {{ t({ zh: '检查', en: 'Check' }) }}
+      </UIButton>
+      <UIButton type="primary" size="medium" @click="handleInfoButtonClick">
+        {{ t({ zh: '信息', en: 'Info' }) }}
+      </UIButton>
+      <UIButton type="secondary" size="medium" @click="handleAnswerButtonClick">
+        {{ t({ zh: '答案', en: 'Answer' }) }}
+      </UIButton>
+    </div>
+    <div class="suggestion-box">
+      <ResultDialog
+        :visible="isCheckingDialogVisible"
+        :title="t({ zh: '代码检查中', en: 'Checking code' })"
+        :content="''"
+        :loading="true"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isNextDialogVisible"
+        :title="t({ zh: '检测结果', en: 'Check Result' })"
+        :content="t({ zh: '太棒了！你的代码检测通过！', en: 'Great! Your code check passed!' })"
+        :button="t({ zh: '下一步', en: 'Next' })"
+        :button-action="'next'"
+        @next="handleNextButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isRetryDialogVisible"
+        :title="t({ zh: '检测结果', en: 'Check Result' })"
+        :content="t({ zh: '错误\n', en: 'Error\n' }) + props.step.tip.zh"
+        :button="t({ zh: '重试', en: 'Retry' })"
+        :button-action="'retry'"
+        @retry="handleRetryButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isAnswerDialogVisible"
+        :title="t({ zh: '参考答案', en: 'Reference Answer' })"
+        :content="''"
+        :is-code="true"
+        :button="t({ zh: '关闭', en: 'Close' })"
+        :button-action="'close'"
+        :code="answer || ''"
+        @close="handleAnswerCloseButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isInfoDialogVisible"
+        :title="t({ zh: '当前步骤', en: 'Current Step' })"
+        :content="t({ zh: props.step.description.zh, en: props.step.description.en })"
+        :button="t({ zh: '关闭', en: 'Close' })"
+        :button-action="'close'"
+        @close="handleInfoCloseButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isTimeoutDialogVisible"
+        :title="t({ zh: '温馨提醒', en: 'Friendly Reminder' })"
+        :content="
+          t({
+            zh: '牛小七发现你卡顿好久了...可以试着点击下方按钮直接查看答案哦～',
+            en: 'Niuxiaoqi found you stuck for a long time... You can click the button below to view the answer directly~'
+          })
+        "
+        :button="t({ zh: '查看答案', en: 'View Answer' })"
+        :button-action="'answer'"
+        @answer="handleAnswerFromTimeout"
+      >
+      </ResultDialog>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
+import UIButton from '../../ui/UIButton.vue'
+import ResultDialog from './ResultDialog.vue'
+import type { Step } from '@/apis/guidance'
+import { urlSafeBase64Decode } from 'qiniu-js'
+import { toText } from '@/models/common/file'
+import { useI18n } from '@/utils/i18n'
+
+const editorCtx = useEditorCtx()
+
+const props = defineProps<{
+  step: Step
+}>()
+
+const emit = defineEmits<{
+  codingStepCompleted: []
path: spx-gui/src/components/guidance/step/CodingStep.vue
user: login: nighca
body: Nit: 可以就叫 `stepCompleted` 的，正如 props 上的 `step` 也没有叫 `codingStep` 一样
created_at: 2025-03-19T07:03:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,296 @@
+<template>
+  <div class="coding-step">
+    <div class="code-button-container">
+      <UIButton type="success" size="medium" @click="handleCheckButtonClick">
+        {{ t({ zh: '检查', en: 'Check' }) }}
+      </UIButton>
+      <UIButton type="primary" size="medium" @click="handleInfoButtonClick">
+        {{ t({ zh: '信息', en: 'Info' }) }}
+      </UIButton>
+      <UIButton type="secondary" size="medium" @click="handleAnswerButtonClick">
+        {{ t({ zh: '答案', en: 'Answer' }) }}
+      </UIButton>
+    </div>
+    <div class="suggestion-box">
+      <ResultDialog
+        :visible="isCheckingDialogVisible"
+        :title="t({ zh: '代码检查中', en: 'Checking code' })"
+        :content="''"
+        :loading="true"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isNextDialogVisible"
+        :title="t({ zh: '检测结果', en: 'Check Result' })"
+        :content="t({ zh: '太棒了！你的代码检测通过！', en: 'Great! Your code check passed!' })"
+        :button="t({ zh: '下一步', en: 'Next' })"
+        :button-action="'next'"
+        @next="handleNextButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isRetryDialogVisible"
+        :title="t({ zh: '检测结果', en: 'Check Result' })"
+        :content="t({ zh: '错误\n', en: 'Error\n' }) + props.step.tip.zh"
+        :button="t({ zh: '重试', en: 'Retry' })"
+        :button-action="'retry'"
+        @retry="handleRetryButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isAnswerDialogVisible"
+        :title="t({ zh: '参考答案', en: 'Reference Answer' })"
+        :content="''"
+        :is-code="true"
+        :button="t({ zh: '关闭', en: 'Close' })"
+        :button-action="'close'"
+        :code="answer || ''"
+        @close="handleAnswerCloseButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isInfoDialogVisible"
+        :title="t({ zh: '当前步骤', en: 'Current Step' })"
+        :content="t({ zh: props.step.description.zh, en: props.step.description.en })"
+        :button="t({ zh: '关闭', en: 'Close' })"
+        :button-action="'close'"
+        @close="handleInfoCloseButtonClick"
+      >
+      </ResultDialog>
+      <ResultDialog
+        :visible="isTimeoutDialogVisible"
+        :title="t({ zh: '温馨提醒', en: 'Friendly Reminder' })"
+        :content="
+          t({
+            zh: '牛小七发现你卡顿好久了...可以试着点击下方按钮直接查看答案哦～',
+            en: 'Niuxiaoqi found you stuck for a long time... You can click the button below to view the answer directly~'
+          })
+        "
+        :button="t({ zh: '查看答案', en: 'View Answer' })"
+        :button-action="'answer'"
+        @answer="handleAnswerFromTimeout"
+      >
+      </ResultDialog>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
+import UIButton from '../../ui/UIButton.vue'
+import ResultDialog from './ResultDialog.vue'
+import type { Step } from '@/apis/guidance'
+import { urlSafeBase64Decode } from 'qiniu-js'
+import { toText } from '@/models/common/file'
+import { useI18n } from '@/utils/i18n'
+
+const editorCtx = useEditorCtx()
+
+const props = defineProps<{
+  step: Step
+}>()
+
+const emit = defineEmits<{
+  codingStepCompleted: []
path: spx-gui/src/components/guidance/step/CodingStep.vue
user: login: neverbiasu
body: 好的
created_at: 2025-03-19T08:25:16Z
author_association: NONE

PR comments:
user: login: qiniu-ci
created_at: 2025-03-19T05:02:07Z
body: 
This PR has been deployed to the preview environment. You can explore it using the [preview URL](http://goplus-builder-pr-1478.goplus-pr-review.svc.jfcs-qa1.local).

> [!WARNING]
> Please note that deployments in the preview environment are temporary and will be automatically cleaned up after a certain period. Make sure to explore it before it is removed. For any questions, contact the Go+ Builder team.

author_association: NONE