CityCanvas.vue 10.5 KB
<script setup>
/**
 * 公会城画面:用 AI Town 的地图与小人素材(本地测试用占位美术,见 public/ai-town/NOTICE.md)在 PixiJS 上绘制。
 * 只负责「把给定的角色画在给定的区域,并让他们走过去」;谁在哪个区、带什么特效由父组件根据服务端事件决定。
 * ponytail: 小人沿直线走向目标,会穿过建筑;要绕路就用地图的碰撞层做 A* 寻路。
 */
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Application, Assets, Container, Graphics, Rectangle, Sprite, Text, Texture } from 'pixi.js'

import * as map from './gentle.js'

const props = defineProps({
  /** @type {import('vue').PropType<Array<{ id: string, name: string, level: number, zone: string, effect: string|null }>>} */
  agents: { type: Array, required: true },
  /** 区域 → 招牌文字。 */
  zoneLabels: { type: Object, required: true },
  ariaLabel: { type: String, required: true },
})

const TILE = map.tiledim
const WALK_PX_PER_SEC = 120
/** 闲逛:没有事件时小人在自己的站位附近慢慢走动,让画面不死;不代表真实行为。 */
const STROLL_PX_PER_SEC = 38
const STROLL_RADIUS = 70
const calm = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const FRAME_MS = 140
/** 各区域在地图上的落脚点(像素,gentle 地图 2048×1536)。 */
const ZONES = Object.freeze({
  hall: { x: 1500, y: 770 },
  desks: { x: 1500, y: 430 },
  bell: { x: 360, y: 450 },
  counter: { x: 330, y: 860 },
})
/** 默认镜头对准的点:四个区域的中间。 */
const FOCUS = Object.freeze({ x: 915, y: 660 })
const EFFECT_TEXT = Object.freeze({ pickup: '📜', bubble: '💬', ring: '🔔', coins: '💰', halo: '⭐' })
const DIRECTIONS = ['down', 'left', 'right', 'up']

const host = ref(null)
/** @type {Application|null} */
let app = null
/** @type {Container|null} */
let world = null
/** @type {Texture|null} */
let folk = null
/** id → 角色的绘制状态。 */
const actors = new Map()
let resizeObserver = null

/** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */
function slot(index) {
  return { x: ((index % 3) - 1) * 124, y: 34 + Math.floor(index / 3) * 64 }
}

/** 同一个 Agent 始终用同一套小人。 */
function characterOf(id) {
  let hash = 0
  for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
  return hash % 8
}

/** 取某套小人某个方向的 3 帧。 */
function framesFor(character, direction) {
  const originX = (character % 4) * 96
  const originY = Math.floor(character / 4) * 128 + DIRECTIONS.indexOf(direction) * 32
  return [0, 1, 2].map((step) => new Texture({ source: folk.source, frame: new Rectangle(originX + step * 32, originY, 32, 32) }))
}

function buildMap(tileset) {
  const layer = new Container()
  const columns = Math.floor(map.tilesetpxw / TILE)
  const cache = new Map()
  const tile = (index) => {
    if (!cache.has(index)) cache.set(index, new Texture({ source: tileset.source, frame: new Rectangle((index % columns) * TILE, Math.floor(index / columns) * TILE, TILE, TILE) }))
    return cache.get(index)
  }
  for (const tiles of [...map.bgtiles, ...map.objmap]) {
    for (let x = 0; x < tiles.length; x += 1) {
      for (let y = 0; y < tiles[x].length; y += 1) {
        if (tiles[x][y] === -1) continue
        const sprite = new Sprite(tile(tiles[x][y]))
        sprite.position.set(x * TILE, y * TILE)
        layer.addChild(sprite)
      }
    }
  }
  return layer
}

function buildSigns() {
  const signs = new Container()
  for (const [zone, point] of Object.entries(ZONES)) {
    const label = new Text({ text: props.zoneLabels[zone], style: { fontSize: 22, fontWeight: '700', fill: 0xffffff, stroke: { color: 0x1f2937, width: 5 } } })
    label.anchor.set(0.5, 1)
    label.position.set(point.x, point.y - 48)
    const plate = new Graphics().roundRect(point.x - 190, point.y - 40, 380, 170, 14).fill({ color: 0xffffff, alpha: 0.18 }).stroke({ color: 0xffffff, alpha: 0.5, width: 2 })
    signs.addChild(plate, label)
  }
  return signs
}

function createActor(agent) {
  const character = characterOf(agent.id)
  const frames = Object.fromEntries(DIRECTIONS.map((direction) => [direction, framesFor(character, direction)]))
  const body = new Sprite(frames.down[0])
  body.anchor.set(0.5, 0.9)
  body.scale.set(1.5)
  const name = new Text({ text: '', style: { fontSize: 15, fontWeight: '600', fill: 0xffffff, stroke: { color: 0x111827, width: 4 } } })
  name.anchor.set(0.5, 1)
  name.position.set(0, -46)
  const effect = new Text({ text: '', style: { fontSize: 24 } })
  effect.anchor.set(0.5, 1)
  effect.position.set(0, -62)
  const node = new Container()
  node.addChild(body, name, effect)
  world.addChild(node)
  return { node, body, name, effect, frames, direction: 'down', home: null, target: null, strolling: false, restUntil: 0, clock: 0 }
}

/** 把最新的角色列表同步到画面:新来的出现在目标点,其余的走过去。 */
function sync() {
  if (world === null) return
  const perZone = {}
  const seen = new Set()
  for (const agent of props.agents) {
    seen.add(agent.id)
    const index = perZone[agent.zone] = (perZone[agent.zone] ?? -1) + 1
    const offset = slot(index)
    const target = { x: ZONES[agent.zone].x + offset.x, y: ZONES[agent.zone].y + offset.y }
    let actor = actors.get(agent.id)
    if (actor === undefined) {
      actor = createActor(agent)
      actor.node.position.set(target.x, target.y)
      actors.set(agent.id, actor)
    }
    // 站位变了(换区域或有演出)才改目的地并快步走过去;否则不打断闲逛。
    if (actor.home === null || actor.home.x !== target.x || actor.home.y !== target.y) {
      actor.home = target
      actor.target = target
      actor.strolling = false
    }
    actor.busy = agent.effect !== null
    actor.name.text = `${agent.name.length > 18 ? `${agent.name.slice(0, 17)}…` : agent.name} · Lv${agent.level}`
    actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect]
  }
  for (const [id, actor] of actors) {
    if (seen.has(id)) continue
    world.removeChild(actor.node)
    actor.node.destroy({ children: true })
    actors.delete(id)
  }
}

function step(ticker) {
  const dt = ticker.deltaMS
  for (const actor of actors.values()) {
    const dx = actor.target.x - actor.node.x
    const dy = actor.target.y - actor.node.y
    const distance = Math.hypot(dx, dy)
    if (distance < 1) {
      actor.body.texture = actor.frames[actor.strolling ? actor.direction : 'down'][0]
      if (calm || actor.busy) continue
      // 到了:歇一会儿,再在站位附近挑个点溜达过去。
      const now = performance.now()
      if (actor.restUntil === 0) actor.restUntil = now + 1500 + Math.random() * 5000
      if (now < actor.restUntil) continue
      actor.restUntil = 0
      actor.strolling = true
      const angle = Math.random() * Math.PI * 2
      const radius = Math.random() * STROLL_RADIUS
      actor.target = { x: actor.home.x + Math.cos(angle) * radius, y: actor.home.y + Math.sin(angle) * radius * 0.6 }
      continue
    }
    const move = Math.min(distance, ((actor.strolling ? STROLL_PX_PER_SEC : WALK_PX_PER_SEC) * dt) / 1000)
    actor.node.x += (dx / distance) * move
    actor.node.y += (dy / distance) * move
    actor.direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up')
    actor.clock += dt
    actor.body.texture = actor.frames[actor.direction][Math.floor(actor.clock / FRAME_MS) % 3]
    // 靠下的角色画在上层。
    actor.node.zIndex = actor.node.y
  }
}

/** 按给定缩放把镜头对准四个区域的中间。 */
function frame(scale) {
  // app.screen 是逻辑像素;renderer.width 是乘过分辨率的物理像素。
  const { width, height } = app.screen
  world.scale.set(scale)
  world.position.set(width / 2 - FOCUS.x * scale, height / 2 - FOCUS.y * scale)
}

function enablePanAndZoom(canvas) {
  let dragging = null
  canvas.addEventListener('pointerdown', (event) => {
    dragging = { x: event.clientX - world.x, y: event.clientY - world.y }
    canvas.setPointerCapture(event.pointerId)
  })
  canvas.addEventListener('pointermove', (event) => {
    if (dragging !== null) world.position.set(event.clientX - dragging.x, event.clientY - dragging.y)
  })
  canvas.addEventListener('pointerup', () => { dragging = null })
  canvas.addEventListener('wheel', (event) => {
    event.preventDefault()
    const next = Math.min(2, Math.max(0.35, world.scale.x * (event.deltaY < 0 ? 1.1 : 0.9)))
    const rect = canvas.getBoundingClientRect()
    const px = event.clientX - rect.left
    const py = event.clientY - rect.top
    // 以鼠标所在点为中心缩放。
    world.position.set(px - ((px - world.x) / world.scale.x) * next, py - ((py - world.y) / world.scale.y) * next)
    world.scale.set(next)
  }, { passive: false })
}

onMounted(async () => {
  const instance = new Application()
  await instance.init({ resizeTo: host.value, background: 0x2a3a2e, antialias: false, autoDensity: true, resolution: Math.min(window.devicePixelRatio, 2) })
  const [tileset, folkTexture] = await Promise.all([Assets.load(map.tilesetpath), Assets.load('/ai-town/32x32folk.png')])
  if (host.value === null) {
    // 加载素材期间页面已离开。
    instance.destroy(true, { children: true })
    return
  }
  tileset.source.scaleMode = 'nearest'
  folkTexture.source.scaleMode = 'nearest'
  app = instance
  folk = folkTexture
  world = new Container()
  world.sortableChildren = true
  const ground = buildMap(tileset)
  ground.zIndex = -2
  const signs = buildSigns()
  signs.zIndex = -1
  world.addChild(ground, signs)
  app.stage.addChild(world)
  host.value.appendChild(app.canvas)
  app.canvas.setAttribute('role', 'img')
  app.canvas.setAttribute('aria-label', props.ariaLabel)
  app.resize()
  // 默认缩放到四个区域都在画面里。
  frame(Math.min(1, host.value.clientWidth / 1700))
  enablePanAndZoom(app.canvas)
  resizeObserver = new ResizeObserver(() => frame(world.scale.x))
  resizeObserver.observe(host.value)
  sync()
  app.ticker.add(step)
})

watch(() => props.agents, sync, { deep: true })

onBeforeUnmount(() => {
  if (resizeObserver !== null) resizeObserver.disconnect()
  if (app !== null) app.destroy(true, { children: true })
  actors.clear()
})
</script>

<template>
  <div ref="host" class="voko-city-canvas" />
</template>

<style scoped>
.voko-city-canvas {
  width: 100%;
  height: min(70vh, 640px);
  overflow: hidden;
  border-radius: var(--drama-radius-md);
  border: 1px solid var(--drama-color-border-strong);
  cursor: grab;
  touch-action: none;
}
</style>