Blue Archive Player 新架构 - ba-archive/blue-archive GitHub Wiki

这是一个全新的 Player 架构,提供新特性,更健壮,更灵活,易拓展。

核心思想

demo 代码:https://github.com/ba-archive/blue-archive/blob/dev-notype/apps/ba-story-editor/scripts/player/player.ts

Pasted image 20231009104336

在该架构下,Player 前端为一个单纯的状态展示机,负责展示当前状态,也称 StoryBoard(分镜)。Player 前端内部维护了一张图 (StoryBoard Map),播放剧情的时候 Player 前端则沿着这一张图前进。并且这一张图是响应式的,可以在播放过程中动态修改节点。播放器前端中负责真正展示演出的叫做幕布(curtain)。curtain 拥有一个指针指向 StoryBoard Map,表示当前展示的 StoryBoard

播放器后端则为播放器前端提供了更强大更灵活的功能支持,它可以在播放的时候与前端通信从而动态修改 StoryBoard Map,也可以直接修改 curtain 的属性从而直接改变播放器前端画面,他几乎可以操控播放器的全部,这对于一些复杂的、动态的情景非常有用。例如:可视化剧情编辑器需要不断对剧情(也就是 StoryBoard Map)进行修改、有些 Story 是可以和用户进行交互的,用户点击按钮,脚本产生随机数进入特定的分支,或者脚本判断在特定的日子里进入特别的分支,或者脚本调用 HTTP API 获取其他资源(比如 ChatGPT)。播放器后端主要包含 3 个部分:Fake PlayerPlayableStoryPlayable 解析 Story 并操控 Fake Player 与播放器前端通信。Story 由剧情编辑器产生并保存由播放器后端加载。

播放模式:播放器可以以三种模式运行

  • static 模式:不需要播放器后端,播放器前端直接加载 StoryBoard Map 播放。
  • dynamic 模式:需要播放器后端,播放器前端加载 StoryBoard Map,播放器后端修改 StoryBoard Map
  • curtain 模式:需要播放器后端,播放器前端不需要 StoryBoard Map,播放器后端直接操作 curtain 内部实例。

播放器前端

播放器前端示例代码,播放器包含许多实例,这些实例的状态也就是 StoryBoard 包含的状态,包含 curtain 绘制所需要的一切信息:

class Player {
  private _characters: (CharacterInstance | null)[] = [null, null, null, null, null]
  private _dialogInstance: DialogInstance = new DialogInstance()
  private _menuInstance: MenuInstance = new MenuInstance()
  public states: PlayerState[] = []
}

curtain

curtain(幕布)负责展示(演出)。他是与用户最近的一层,它负责展示对话框、展示角色、播放动画、更改背景、播放声音。他还负责收集用户的反馈,比如用户修改菜单、用户点击屏幕、用户选择分支。curtain 按照 mvvm 架构设计的。curtain 中包含了许多实例(mv),这些实例保存了展示所需的一切信息。

例如角色实例,当一个角色实例被创建并初始化的时候,curtain 就会绘制出一个角色,这个角色是按照创建出来的角色实例绘制的,包括面部表情(face)、表情气泡(emotion)、角色立绘(spine)、角色位置。当修改实例的时候,角色会被重新绘制。

例如菜单实例,它包含一些属性例如:位置、是否隐藏、按钮打开状态。用户点击按钮的时候按钮实例里面的属性也会随之更新。是双向绑定。

角色实例代码示例,角色实例包含了绘制角色需要的属性:

class CharacterInstance {
  private _name: string
  private _group: string
  private _position: 1 | 2 | 3 | 4 | 5
  private _face: number
  private _emotion: string
  private _effect?: string /** 对元素图层操作 */
  private _animation?: Animation /** 控制元素坐标(transform),不会改变 position */
  private _animationState: AnimationState = {}

  constructor(args: { name: string; position: 1 | 2 | 3 | 4 | 5; effect?: string; animation?: Animation }) {
    this._name = args.name
    this._group = 'group'
    this._position = args.position
    this._face = 1
    this._emotion = 'emotion'
    this._effect = args.effect
    this._animation = args.animation
  }

  get name() { return this._name }
  get group() { return this._group }
  get position() { return this._position }
  get emotion() { return this._emotion }
  get face() { return this._face }
  get effect() { return this._effect }
  get animation() { return this._animation }
  get animationState() { return this._animationState }
}

下面是 curtain 展示某个 StoryBoard 时,curtain 所包含的实例的图例

Pasted image 20231009110821

值得一提的是动画的展示。动画比较特殊,有些动画可以横跨多个 StoryBoard,动画也有不同的时间轴(也就是可以多个动画并行播放)。如何解决这几个问题?

StoryBoard

StoryBoard 译为分镜,何为分镜?在漫画上我们也可以将之称为分格。分镜用以解说一个场景将如何构成。分镜画面主要以4个方面组成:镜号、画面、描述、时间。例如以下一张图表示一个分镜

7c239969739d128f4d81d1fbc5070b96

其中交代了一些信息:背景、文本、人物、面部表情(face)、表情动画(emotion)。分镜还有一些隐藏信息:在 StoryBoard Map 中的位置、一些未展示出来的组件(title、place、st)。

StoryBoard duration (分镜持续时间)。即为 curtain 展示完一个分镜所需要的时间,一个分镜不是一瞬间就展示完毕的(等待动画播放完毕),通常来说分镜展示 1-5 秒左右的画面。

当一个 StoryBoard 传递给播放器前端的时候,播放器会根据 StoryBoard 来设置 curtain 内部的实例,然后 curtain 绘制画面,这一过程称作 绘制(paint)。然后进入 动画(animate) 状态。 curtain 通过扫描每个 Animatable(可动画实例),查询该实例是否具有 Animation(动画实例) ,如果有则将该动画实例作用于该 Animatable 播放动画。

Animation

动画部分示例代码:

interface AnimationState {
  position?: [number, number] // absolute position
  effect?: Effect
}

interface Animatable {
  position: [number, number]
  effect: Effect
  animationState: AnimationState
}

interface Animation {
  readonly name: string
  delay: number
  duration: number
  iterationCount: number
  animate: (obj: Animatable, timeline: number) => void
  final: (obj: Animatable) => void
}

解释:Animatable 指可以进行动画的实例,其中包含了一些播放动画所需属性:位置、效果、动画状态,用来实现位置动画和特效动画。Animation 指动画接口,它定义了一个动画,其中包含了一个动画函数(animate),这个函数接受两个参数:objtimeline。该函数根据 timeline 来设置 obj 的属性。final 函数需要将 obj 设置为动画完毕后 obj 的状态。

注意:animate 函数操作的是 AnimationStateobj 操作的是 Animatable。动画过程中 Animatable.position 不会改变,改变的是 Animatable.animationState.position。可以类比 translate 动画。

示例动画示例代码:

// example implements of Animation, consider ScreenX === 1600
class MoveLeft implements Animation {
  readonly name = 'move-left'
  delay: number
  duration: number
  iterationCount: number

  constructor(delay = 0, duration = 1000, iterationCount = 1) {
    this.delay = delay
    this.duration = duration
    this.iterationCount = iterationCount
  }

  animate(obj: Animatable, timeline: number) {
    // position[0] is x, move left 320 px, duration 1000ms, fps is 60, animate() calls 60 times, every time move left 320 / 60px
    obj.animationState.position = [obj.position[0] - 320 * timeline / 1000, obj.position[1]]
  }

  final(obj: Animatable) {
    obj.position[0] = obj.position[0] - 320
    obj.animationState = {}
  }

  toString() {
    return `[Animation name="${this.name}"]`
  }
}

StoryBoard Map

StoryBoard Map 是一张有向有环图。每一个节点表示一个 StoryBoard(分镜)。有一个指针(currentStoryBoard)指向该图的某一个节点,表示当前 curtain 所应该展示的 StoryBoard,即当前进行到的剧情分镜。

播放器进行 StoryBoard Map 播放的过程:播放器前端初始化后,StoryBoard Map 也初始化,currentStoryBoard 指向 StoryBoard Map 的开始节点,curtain 加载 currentStoryBoard 所指向的 StoryBoard 展示,curtain 获取用户的交互(可能是选择条件分支,或者点击屏幕进入下一个分镜),根据 StoryBoard 定义的逻辑移动 currentStoryBoard 指针跳转到下一个节点。

StoryBoard Map 可以在播放的过程中动态被修改,播放器前端只关注 currentStoryBoard 所指节点。

播放器后端

播放器后端是和播放器前端独立的部分,两着通过特定协议进行通信,两者可以运行在同一宿主上,也可以运行在不同宿主上通过 API 来通信。播放器可以通过两种方法来控制播放器前端。第一种是通过同台修改 StoryBoard Map 来改变剧情走向,第二种则是直接修改 curtain 内部实例来控制画面。第二种方法没有使用 StoryBoard Map,是一种底层方式。

需要使用到播放器后端的情景通常涉及到动态修改剧情,比如说剧情播放器, 根据用户交互修改剧情。这些动态的逻辑由 StoryScript 包含。

播放器后端与播放器前端通信使用的是一个叫做 Fake Player 的东西,后端代码调用 Fake Player 的接口,Fake Player 会将其传递给播放器前端。

剧情格式

播放器需要剧情剧情可以用许多种格式存储。剧情格式可以分为三大类:

  • StoryBoard Map (.smb)
  • Story (静态剧情)
  • Script (动态剧情)

动态剧情包含脚本,图灵完备,可以根据用户的输入来操作整个播放器架构。静态剧情不包含脚本,只拥有最基本的条件选择跳转命令。

StoryBoard Map 直接以图结构储存了 StoryBoard Map。它可以直接由播放器前端加载播放。Story 通常保存一些静态的剧情,Script 则保存动态剧情。

下面是一些剧情格式

  • StoryBoard Map (.smb):直接以图结构储存剧情,每一个节点都是 StoryBoard。它可以直接由播放器前端加载播放
  • NexonJSONStory:Nexon 原始 json 格式
  • NexonScript:从 NexonJSONStory 提取出来的,可以和 NexonJSONStory 无缝转换,图灵不完备,主要提取 ScriptKr 字段
  • PlanaScript:新的一种剧情脚本,如果有些人不喜欢可视化编辑器则使用这个,图灵完备,需要解释器转换成 Playable
  • VisualPlanaScript:可视化编辑器产生的脚本格式。是图数据结构,可视化变成。可以转换成 PlanaScript,也可以直接转换成 Playable
  • KotlinScript:使用 Kotlin 的 DSL 功能来制作剧情。直接转换成 Playable

Playable

interface Playable {
  play: (player: FakePlayer) => void
}

任何剧情都是 Playable(可播放) 的。Playable 是一个接口,它接受一个 Fake Player,通过在他的 play 方法中操作 Fake Player 来控制播放器前端播放剧情。所有剧情格式都需要转换成 Playable 才能播放,这里提供操作播放器最直接的 API。

例如:NexonScript 通过 NexonScriptPlayable 转换成 Playable,然后将 Fake Player 传给这个 Playable,运行 play 函数,播放器播放这个 NexonScript 的剧情。

剧情封装(rfcs)

剧情格式 索引资源的方式是通过 Asset,也就是剧情格式获取媒体资源只考虑 Asset,不考虑实际如何获取资源,通过 Asset 来获取实际资源。

剧情封装 是一个压缩文件系统,里面包含了一些元数据、以特定 剧情格式 封装的剧情、一些媒体资源。剧情封装可以用来将播放一段(章)剧情所需要的全部资源都整合到一起,下载剧情封装就可以不依赖网络从而离线播放剧情,便于传播。

Asset 可以通过多种方式来获取具体的媒体资源:url、剧情封装相对路径……

DialogInstance

curtain 对话框实例的设计示例

DialogInstancecurtain 的一个组件,用于存储对话框的状态。对话框需要包含:对话人、对话人小组、对话内容三项基本信息。同时,展示对话内容需要应用打字机特效,对话内容有时候还会有粗体、标注、字体大小等样式变化。

示例代码:

export enum Lang {
  zhCN = 'zh-CN',
  zhTW = 'zh-TW',
  en = 'en',
  jp = 'jp',
  kr = 'kr',
}

export type I18N<T> = {
  [key in Lang]?: T
}

export type I18NString = I18N<string>

export enum NexonTags {
  Color = 'Color',
  Ruby = 'Ruby',
  Tooltip = 'Tooltip',
  B = 'B',
  I = 'i',
  Log = 'Log',
  Root = 'Root',
}

export interface TextAST {
  tag: NexonTags
  children: (TextAST | string)[]
  value?: string // argument (unlike xml, nexon tag has only one argument)
}

export type I18NTextAST = I18N<TextAST>

class DialogInstance {
  private _content?: I18NTextAST
  private _speaker?: I18NString
  private _group?: I18NString

  constructor(content?: I18NTextAST, speaker?: I18NString, group?: I18NString) {
    this._content = content
    this._speaker = speaker
    this._group = group
  }

  get content() { return this._content }
  get speaker() { return this._speaker }
  get group() { return this._group }

  setValue(content?: I18NTextAST, speaker?: I18NString, group?: I18NString) {
    this._content = content
    this._speaker = speaker
    this._group = group
  }
}

这段代码需要注意对 I18N 的设计,传给 DialogInstance 的不是 String 而是 I18NString

为了解决对话内容样式问题,DialogInstance.content 的类型是 I18NTextAST,这是一个抽象文本树,方便解析样式。

概念

  • 播放器前端
  • 播放器后端
  • 播放器:根据上下文有不同的语义,可以指播放器前端也可以指播放器后端,这样会有歧义,通常指 Blue Archive Story Player 这一个整体
  • 播放器 播放模式
  • curtain(幕布):播放器前端真正用来绘制画面的
  • Fake Player:播放器后端用于和前端进行通信的代理类
  • Playable
  • StoryBoard(分镜)
  • StoryBoard Map(分镜图)
  • curtain 组件:curtain 保存一些组件的实例,每一个实例涉及到绘制画面部分组件。
  • 剧情:指最终传达给用户体验的信息,剧情需要通过播放器演出来传达给用户
  • 剧情格式:保存剧情,播放器理解剧情的方式
  • 剧情封装:将剧情和剧情资源封装在一个压缩文件系统里
  • 剧情资源:一些剧情播放时需要应用的媒体资源
  • Asset:抽象的资产接口,被剧情格式所使用
  • 静态剧情
  • 动态剧情
⚠️ **GitHub.com Fallback** ⚠️