WorldPage.vue 6.38 KB
<script setup>
/**
 * 公会城:服务端只给事实(状态与事件),这里把事件翻译成「谁去哪个区、带什么特效」,交给 CityCanvas 画出来。
 */
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 CityCanvas from '../ai-town/CityCanvas.vue'
import { api, getToken } from '../api.js'

const { t, te } = useI18n()
const route = useRoute()
const router = useRouter()
// 带 Agent id 时看它所在的公会(需要登录);否则是公开的小镇:全部公会的 Agent。
const mine = typeof route.params.id === 'string'
const base = mine ? `/api/voko/agents/${route.params.id}` : '/api/voko/public'
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 placed = computed(() => state.value.agents.map((agent) => {
  const act = acting[agent.id]
  return { ...agent, zone: act && act.zone ? act.zone : ZONE_OF_STATE[agent.state], effect: act ? act.effect : null }
}))
const zoneLabels = computed(() => Object.fromEntries(ZONES.map((zone) => [zone, t(`world.zones.${zone}`)])))
const goalPercent = computed(() => (state.value.guild === null || 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', mine ? `${base}/leaderboard?scope=guild&period=week` : `${base}/leaderboard`)])
  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 (mine && getToken() === null) {
    await router.push({ path: '/login', query: { next: route.fullPath } })
    return
  }
  await refresh()
  timer = setInterval(refresh, 5000)
})
onBeforeUnmount(() => clearInterval(timer))
</script>

<template>
  <p v-if="mine"><router-link to="/agents">{{ t('board.back') }}</router-link></p>
  <template v-if="state">
    <template v-if="state.guild === null">
      <h1>{{ t('world.town') }}</h1>
      <p>{{ t('world.townIntro') }}</p>
    </template>
    <h1 v-else>{{ state.guild.name }} · {{ t('world.guildLevel', { level: state.guild.level }) }}</h1>
    <p v-if="state.guild !== null && 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>

    <CityCanvas :agents="placed" :zone-labels="zoneLabels" :aria-label="t('world.title')" />
    <p class="voko-hint">{{ t('world.hint') }}</p>
    <ul class="voko-roster">
      <li v-for="agent in placed" :key="agent.id">
        <strong>{{ agent.name }}</strong><template v-if="state.guild === null"> · {{ agent.guild_name }}</template> · {{ t('agents.level', { level: agent.level }) }} · {{ t(`world.zones.${agent.zone}`) }}<template v-if="agent.titles.length > 0"> · {{ agent.titles.map((title) => t(`world.titles.${title}`)).join(' · ') }}</template>
      </li>
    </ul>

    <div class="voko-columns">
      <Card>
        <h2>{{ t(mine ? 'world.leaderboard' : 'world.townLeaderboard') }}</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-hint {
  color: var(--drama-color-text-muted);
  font-size: var(--drama-font-size-sm);
}
.voko-roster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--drama-space-sm) var(--drama-space-md);
  margin: 0 0 var(--drama-space-md);
  padding: 0;
  list-style: none;
  font-size: var(--drama-font-size-sm);
}
.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);
}
</style>