WorldPage.vue 8.19 KB
<script setup>
/**
 * 公会城:服务端只给事实(状态与事件),这里把事件翻译成角色的移动与特效。
 * ponytail: 先用 DOM + CSS 过渡画(50 个角色只动 transform,足够流畅);美术素材定稿后换成 @drama/render-pixi 场景。
 */
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Card } from '@drama/ui'

import { api, getToken } from '../api.js'

const { t, te } = useI18n()
const route = useRoute()
const router = useRouter()
const base = `/api/voko/agents/${route.params.id}`
const state = ref(null)
const board = ref([])
const feed = ref([])
/** 事件触发的临时演出:agent_id → { zone, effect },几秒后回到常态。 */
const acting = reactive({})
let cursor = 0
let timer = null

const ZONES = ['hall', 'desks', 'bell', 'counter']
const ZONE_OF_STATE = { idle: 'hall', working: 'desks', help: 'bell' }
/** 事件 → 谁去哪儿、带什么特效。 */
const ACTS = {
  'task.claimed': (event) => [event.task.holder_agent_id, 'hall', 'pickup'],
  'task.checkpoint': (event) => [event.task.holder_agent_id, 'desks', 'bubble'],
  'help.requested': (event) => [event.task.holder_agent_id, 'bell', 'ring'],
  'task.accepted': (event) => [event.agent_id, 'counter', 'coins'],
  'task.auto_accepted': (event) => [event.task.holder_agent_id, 'counter', 'coins'],
  'tip.received': (event) => [event.agent_id, 'counter', 'coins'],
  'agent.level_up': (event) => [event.agent_id, null, 'halo'],
  'agent.title_awarded': (event) => [event.agent_id, null, 'halo'],
}

const zones = computed(() => {
  const placed = Object.fromEntries(ZONES.map((zone) => [zone, []]))
  for (const agent of state.value.agents) {
    const act = acting[agent.id]
    placed[act && act.zone ? act.zone : ZONE_OF_STATE[agent.state]].push({ ...agent, effect: act ? act.effect : null })
  }
  return placed
})
const goalPercent = computed(() => (state.value.guild.weekly_goal === 0 ? 0 : Math.min(100, Math.round((state.value.guild.weekly_progress / state.value.guild.weekly_goal) * 100))))

/** @param {any} event */
function play(event) {
  const act = ACTS[event.type]
  if (act === undefined) return
  const [agentId, zone, effect] = act(event)
  if (!agentId) return
  acting[agentId] = { zone, effect }
  setTimeout(() => { delete acting[agentId] }, 4000)
}

async function refresh() {
  const [nextState, nextFeed, nextBoard] = await Promise.all([api('GET', `${base}/world/state`), api('GET', `${base}/world/feed?since=${cursor}`), api('GET', `${base}/leaderboard?scope=guild&period=week`)])
  state.value = nextState
  board.value = nextBoard.leaderboard
  const fresh = nextFeed.events
  if (fresh.length > 0) {
    // 首屏只铺动态列表,不回放历史动画;之后的新事件才演出。
    if (cursor > 0) fresh.forEach(play)
    cursor = fresh.at(-1).id
    feed.value = [...fresh.reverse(), ...feed.value].slice(0, 30)
  }
}

/** @param {any} event */
function describe(event) {
  const who = state.value.agents.find((agent) => agent.id === (event.agent_id || (event.task && event.task.holder_agent_id)))
  // vue-i18n 把点号当路径分隔符,文案键用下划线。
  const key = `world.events.${event.type.replace('.', '_')}`
  return { text: te(key) ? t(key) : event.type, who: who ? who.name : '', task: event.task ? event.task.title : '' }
}

onMounted(async () => {
  if (getToken() === null) {
    await router.push({ path: '/login', query: { next: route.fullPath } })
    return
  }
  await refresh()
  timer = setInterval(refresh, 5000)
})
onBeforeUnmount(() => clearInterval(timer))
</script>

<template>
  <p><router-link to="/agents">{{ t('board.back') }}</router-link></p>
  <template v-if="state">
    <h1>{{ state.guild.name }} · {{ t('world.guildLevel', { level: state.guild.level }) }}</h1>
    <p v-if="state.guild.weekly_goal > 0">
      {{ t('world.goal', { progress: state.guild.weekly_progress, goal: state.guild.weekly_goal }) }}
      <progress :value="goalPercent" max="100">{{ goalPercent }}%</progress>
    </p>

    <section class="voko-city" :aria-label="t('world.title')">
      <div v-for="zone in ZONES" :key="zone" class="voko-zone">
        <h2>{{ t(`world.zones.${zone}`) }}</h2>
        <TransitionGroup name="voko-walk" tag="ul" class="voko-crowd">
          <li v-for="agent in zones[zone]" :key="agent.id" class="voko-agent" :class="[`is-${agent.state}`, agent.effect ? `fx-${agent.effect}` : '']" :title="`${agent.name} · ${t('agents.level', { level: agent.level })}`">
            <span class="voko-face" aria-hidden="true">{{ agent.name.slice(0, 1).toUpperCase() }}</span>
            <span class="voko-name">{{ agent.name }}</span>
            <span class="voko-meta">{{ t('agents.level', { level: agent.level }) }}<template v-if="agent.titles.length > 0"> · {{ agent.titles.map((title) => t(`world.titles.${title}`)).join(' · ') }}</template></span>
          </li>
        </TransitionGroup>
      </div>
    </section>

    <div class="voko-columns">
      <Card>
        <h2>{{ t('world.leaderboard') }}</h2>
        <p v-if="board.length === 0">{{ t('task.none') }}</p>
        <ol><li v-for="row in board" :key="row.agent_id">{{ row.name }} · {{ t('board.reward', { reward: row.points }) }}</li></ol>
      </Card>
      <Card>
        <h2>{{ t('world.feed') }}</h2>
        <p v-if="feed.length === 0">{{ t('task.none') }}</p>
        <ul class="voko-feed" aria-live="polite">
          <li v-for="event in feed" :key="event.id">{{ new Date(event.at).toLocaleTimeString() }} · {{ describe(event).who }} {{ describe(event).text }} <em v-if="describe(event).task">{{ describe(event).task }}</em></li>
        </ul>
      </Card>
    </div>
  </template>
</template>

<style scoped>
.voko-city {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--drama-space-md);
  margin-bottom: var(--drama-space-md);
}
.voko-zone {
  min-height: 180px;
  padding: var(--drama-space-md);
  border: 1px dashed var(--drama-color-border-strong);
  border-radius: var(--drama-radius-md);
  background: var(--drama-color-surface);
}
.voko-zone h2 {
  margin: 0 0 var(--drama-space-sm);
  font-size: var(--drama-font-size-sm);
  color: var(--drama-color-text-muted);
}
.voko-crowd {
  display: flex;
  flex-wrap: wrap;
  gap: var(--drama-space-sm);
  margin: 0;
  padding: 0;
  list-style: none;
}
.voko-agent {
  display: grid;
  justify-items: center;
  width: 84px;
  text-align: center;
}
.voko-face {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid var(--drama-color-border-strong);
  background: var(--drama-color-bg);
  font-weight: 700;
}
.is-working .voko-face {
  border-color: var(--drama-color-primary);
}
.is-help .voko-face {
  border-color: var(--drama-color-danger);
}
.voko-name {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--drama-font-size-sm);
}
.voko-meta {
  font-size: var(--drama-font-size-sm);
  color: var(--drama-color-text-muted);
}
.fx-coins .voko-face,
.fx-halo .voko-face,
.fx-pickup .voko-face {
  animation: voko-pop 0.8s ease-in-out 3;
}
.fx-halo .voko-face {
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--drama-color-primary) 30%, transparent);
}
.fx-coins .voko-face {
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--drama-color-success) 35%, transparent);
}
.fx-ring .voko-face,
.fx-bubble .voko-face {
  animation: voko-shake 0.4s ease-in-out 6;
}
.voko-walk-move,
.voko-walk-enter-active,
.voko-walk-leave-active {
  transition: transform 0.6s ease, opacity 0.6s ease;
}
.voko-walk-enter-from,
.voko-walk-leave-to {
  opacity: 0;
  transform: translateY(12px);
}
.voko-walk-leave-active {
  position: absolute;
}
.voko-columns {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--drama-space-md);
}
.voko-feed {
  margin: 0;
  padding-left: var(--drama-space-md);
}
@keyframes voko-pop {
  50% { transform: scale(1.25); }
}
@keyframes voko-shake {
  25% { transform: rotate(-12deg); }
  75% { transform: rotate(12deg); }
}
@media (prefers-reduced-motion: reduce) {
  .voko-face { animation: none !important; }
  .voko-walk-move, .voko-walk-enter-active, .voko-walk-leave-active { transition: none; }
}
</style>