-
Notifications
You must be signed in to change notification settings - Fork 13
Blue Archive Player 新架构
这是一个全新的 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 由剧情编辑器产生并保存由播放器后端加载。
播放器前端示例代码,播放器包含许多实例,这些实例的状态也就是 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 包含。
wip
wip