Blue Archive Player 新架构 - ba-archive/blue-archive GitHub Wiki
这是一个全新的 Player
架构,提供新特性,更健壮,更灵活,易拓展。
在该架构下,Player
前端为一个单纯的状态展示机,负责展示当前状态,也称 StoryBoard
(分镜)。Player 前端内部维护了一张图 (StoryBoard Map),播放剧情的时候 Player
前端则沿着这一张图前进。并且这一张图是响应式的,可以在播放过程中动态修改节点。播放器前端中负责真正展示演出的叫做幕布(curtain)。curtain
拥有一个指针指向 StoryBoard Map
,表示当前展示的 StoryBoard
。
播放器后端则为播放器前端提供了更强大更灵活的功能支持,它可以在播放的时候与前端通信从而动态修改 StoryBoard Map
,也可以直接修改 curtain 的属性从而直接改变播放器前端画面,他几乎可以操控播放器的全部,这对于一些复杂的、动态的情景非常有用。例如:可视化剧情编辑器需要不断对剧情(也就是 StoryBoard Map
)进行修改、有些 Story
是可以和用户进行交互的,用户点击按钮,脚本产生随机数进入特定的分支,或者脚本判断在特定的日子里进入特别的分支,或者脚本调用 HTTP API
获取其他资源(比如 ChatGPT)。播放器后端主要包含 3 个部分:Fake Player
、Playable
、Story
。Playable
解析 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 按照 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 所包含的实例的图例
值得一提的是动画的展示。动画比较特殊,有些动画可以横跨多个
StoryBoard
,动画也有不同的时间轴(也就是可以多个动画并行播放)。如何解决这几个问题?
StoryBoard 译为分镜,何为分镜?在漫画上我们也可以将之称为分格。分镜用以解说一个场景将如何构成。分镜画面主要以4个方面组成:镜号、画面、描述、时间。例如以下一张图表示一个分镜
其中交代了一些信息:背景、文本、人物、面部表情(face)、表情动画(emotion)。分镜还有一些隐藏信息:在 StoryBoard Map 中的位置、一些未展示出来的组件(title、place、st)。
StoryBoard duration
(分镜持续时间)。即为 curtain
展示完一个分镜所需要的时间,一个分镜不是一瞬间就展示完毕的(等待动画播放完毕),通常来说分镜展示 1-5 秒左右的画面。
当一个 StoryBoard
传递给播放器前端的时候,播放器会根据 StoryBoard
来设置 curtain
内部的实例,然后 curtain
绘制画面,这一过程称作 绘制(paint)
。然后进入 动画(animate)
状态。 curtain 通过扫描每个 Animatable(可动画实例)
,查询该实例是否具有 Animation(动画实例)
,如果有则将该动画实例作用于该 Animatable
播放动画。
动画部分示例代码:
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
),这个函数接受两个参数:obj
、timeline
。该函数根据 timeline
来设置 obj
的属性。final
函数需要将 obj
设置为动画完毕后 obj
的状态。
注意:
animate
函数操作的是AnimationState
,obj
操作的是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
(分镜)。有一个指针(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
interface Playable {
play: (player: FakePlayer) => void
}
任何剧情都是 Playable(可播放)
的。Playable
是一个接口,它接受一个 Fake Player
,通过在他的 play 方法中操作 Fake Player
来控制播放器前端播放剧情。所有剧情格式都需要转换成 Playable
才能播放,这里提供操作播放器最直接的 API。
例如:NexonScript
通过 NexonScriptPlayable
转换成 Playable
,然后将 Fake Player
传给这个 Playable
,运行 play
函数,播放器播放这个 NexonScript
的剧情。
剧情格式
索引资源的方式是通过 Asset
,也就是剧情格式获取媒体资源只考虑 Asset
,不考虑实际如何获取资源,通过 Asset
来获取实际资源。
剧情封装
是一个压缩文件系统,里面包含了一些元数据、以特定 剧情格式
封装的剧情、一些媒体资源。剧情封装可以用来将播放一段(章)剧情所需要的全部资源都整合到一起,下载剧情封装就可以不依赖网络从而离线播放剧情,便于传播。
Asset
可以通过多种方式来获取具体的媒体资源:url、剧情封装相对路径……
curtain
对话框实例的设计示例
DialogInstance
是 curtain
的一个组件,用于存储对话框的状态。对话框需要包含:对话人、对话人小组、对话内容三项基本信息。同时,展示对话内容需要应用打字机特效,对话内容有时候还会有粗体、标注、字体大小等样式变化。
示例代码:
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:抽象的资产接口,被剧情格式所使用
- 静态剧情
- 动态剧情