/* global platypus */
import {arrayCache, greenSlice} from '../utils/array.js';
import AudioVO from './AudioVO.js';
import createComponentClass from '../factory.js';
const
getEventName = function (msg, VO) {
if (VO === ' ') {
return msg + 'default';
} else {
return msg + VO;
}
},
createAudioDefinition = function (sound, events = [], message, frameLength) {
const
soundId = typeof sound === 'string' ? sound : typeof sound.sound === 'string' ? sound.sound : '',
soundObj = soundId ? {} : sound.sound,
definition = {
sound: soundId,
events: [...sound.events ?? []],
...soundObj
},
voice = sound.voice,
mouthCues = sound.mouthCues ?? platypus.game.settings.mouthCues?.[definition.sound] ?? platypus.game.settings.mouthCues?.[definition.sound.substring(definition.sound.lastIndexOf('/') + 1)];
if (voice) {
let lastFrame = null,
time = 0;
voice += ' ';
for (let i = 0; i < voice.length; i++) {
const
thisFrame = voice[i];
if (thisFrame !== lastFrame) {
lastFrame = thisFrame;
definition.events.push({
time,
event: getEventName(message, thisFrame)
});
}
time += frameLength;
}
} else if (mouthCues) {
// if in condensed format
if (typeof mouthCues[0] === 'number') {
const
length = (mouthCues.length / 2) >> 0;
for (let i = 0; i < length; i++) {
const
index = i * 2;
definition.events.push({
time: mouthCues[index] * 1000,
event: getEventName(message, mouthCues[index + 1])
});
}
} else {
for (let i = 0; i < mouthCues.length; i++) {
const
thisFrame = mouthCues[i];
definition.events.push({
time: thisFrame.start * 1000,
event: getEventName(message, thisFrame.value)
});
}
}
}
return definition;
},
createVO = function (sound, events, message, frameLength) {
if (!events[' ']) {
events[' '] = events.default;
}
if (Array.isArray(sound)) {
return sound.map((clip) => {
if (typeof clip === 'number' || typeof clip === 'function') {
return clip;
} else {
return createAudioDefinition(clip, events, message, frameLength);
}
});
} else {
return createAudioDefinition(sound, events, message, frameLength);
}
};
export default createComponentClass(/** @lends platypus.components.VoiceOver.prototype */{
id: 'VoiceOver',
properties: {
aliases: null,
/**
* Sets the pairing between letters in the voice-over strings and the animation frame to play.
*
* "animationMap": {
* "default": "mouth-closed"
* // Required. Specifies animation of default position.
*
* "w": "mouth-o",
* "a": "mouth-aah",
* "t": "mouth-t"
* // Optional. Also list single characters that should map to a given voice-over animation frame.
* }
*
* @property animationMap
* @type Object
* @default: {"default": "default"}
*/
animationMap: {"default": "default"},
/**
* Specifies the type of component to add to handle VO lip-sync animation.
*
* @property renderComponent
* @type String
* @default 'RenderSprite'
*/
renderComponent: 'RenderSprite',
/**
* Specifies how long a described voice-over frame should last in milliseconds.
*
* @property frameLength
* @type Number
* @default 100
*/
frameLength: 100,
/**
* Specifies the prefix that messages between the render and Audio components should use. This will cause the audio to trigger events like "i-say-w" and "i-say-a" (characters listed in the animationMap), that the RenderSprite uses to show the proper frame.
*
* @property messagePrefix
* @type String
* @default ""
*/
messagePrefix: "",
/**
* This maps events to audio clips and voice over strings.
*
* "voiceOverMap": {
* "message-triggered": [{
* "sound": "audio-id",
* // Required. This is the audio clip to play when "message-triggered" is triggered. It may be a string as shown or an object of key/value pairs as described in an [[audio]] component definition.
* "voice": "waat"
* // Optional. This string defines the voice-over sequence according to the frames defined by animationMap. Each character lasts the length specified by "frameLength" above. If not specified, voice will be the default frame.
* }]
* }
*
* @property voiceOverMap
* @type Object
* @default null
*/
voiceOverMap: null,
/**
* This generates voice over maps. An array of specifications for batches of voice maps to generate. Includes basic properties that can add a prefix to the event name, initial delay before the audio, and an onEnd event that fires when the voice over completes.
*
* "generatedVoiceOverMap": [{
* "eventPrefix": "vo-" //Optional. Defaults to "vo-". Is prefixed to the audio file name to create the event to call to trigger to VO.
* "initialDelay": 0 //Optional. Defaults to 0. An intial audio delay before the VO starts. Useful to prevent audio from triggering as a scene is loading.
* "onEndEvent": "an-event" //Optional. Defaults to "". This event fires when the VO completes.
* "endEventTime": 500 //Optional. Defaults to 99999. When the onEnd event fires.
* "audio": ["audio-0", "audio-1", "audio-2"] //Required. An array of strings that coorespond to the audio files to create a VOMap for, or a key/value list of id to audio path pairings.
* }]
*
* A generated VO Map is equivalent to this structure:
*
* "prefix-audio-0": [
* 500, //initialDelay
* {
* "sound": {
* "sound": "audio-0", //the audio string
* "events": [
* {
* "event": "on-end-event", //onEndEvent
* "time": 99999
* }
* ]
* }
* }
* ],
*
* @property generatedVoiceOverMap
* @type Object[]
* @default null
*/
generatedVoiceOverMaps: null,
acceptInput: null,
animation: null,
flip: null,
hidden: null,
interactive: null,
mask: null,
mirror: null,
offsetZ: null,
regX: null,
regY: null,
restart: null,
scaleX: null,
scaleY: null,
spriteSheet: null,
stateBased: null,
},
/**
* This component uses its definition to load an AudioVO component and a RenderSprite component. These work in an interconnected way to render animations corresponding to one or more audio tracks.
*
* In addition to its own properties, this component also accepts all properties accepted by [RenderSprite](platypus.components.RenderSprite.html) and [AudioVO](platypus.components.AudioVO.html) and passes them along when it creates those components.
*
* @memberof platypus.components
* @uses platypus.Component
* @uses platypus.AudioVO
* @uses platypus.RenderSprite
* @constructs
* @listens platypus.Entity#load
*/
initialize: function (definition, callback) {
const
{aliases, acceptInput, animation, animationMap, flip, hidden, interactive, mask, messagePrefix, mirror, offsetZ, owner, regX, regY, renderComponent, restart, scaleX, scaleY, spriteSheet, stateBased, voiceOverMap = {}} = this,
animationKeys = Object.keys(animationMap),
{length: animationLength} = animationKeys,
componentInit = (Component, definition) => new Promise((resolve) => {
owner.addComponent(new Component(owner, definition, resolve));
}),
voMapKeys = Object.keys(voiceOverMap),
{length: voMapLength} = voMapKeys,
audioDefinition = {
audioMap: {},
aliases
},
animationDefinition = {
acceptInput,
aliases,
animation,
animationMap: {},
eventBased: true, // VO triggers events for changing lip-sync frames.
flip,
hidden,
interactive,
mask,
mirror,
offsetZ,
regX,
regY,
restart,
scaleX,
scaleY,
spriteSheet,
stateBased
};
if (messagePrefix) {
this.message = `${messagePrefix}-`;
} else {
this.message = '';
}
this.relayLipSyncEvents = ({descriptor, content}) => {
if (descriptor === 'lipsync') {
this.owner.triggerEvent(getEventName(this.message, content));
}
};
platypus.game.voPlayer.on('lyric', this.relayLipSyncEvents);
for (let i = 0; i < animationLength; i++) {
const
key = animationKeys[i];
animationDefinition.animationMap[getEventName(this.message, key)] = animationMap[key];
}
animationDefinition.animationMap.default = this.animationMap.default;
if (this.generatedVoiceOverMaps) {
const
createMapping = (key, path, voBatch) => {
if (!this.voiceOverMap[key]) {
const
delay = voBatch.initialDelay || 0,
endEventTime = voBatch.endEventTime || 99999,
onEnd = voBatch.onEndEvent || "";
this.voiceOverMap[key] = [
delay,
{
"sound": {
"sound": path,
"events": [
{
"event": onEnd,
"time": endEventTime
}
]
}
}
];
}
};
for (let y = 0; y < this.generatedVoiceOverMaps.length; y++) {
const
voBatch = this.generatedVoiceOverMaps[y],
prefix = voBatch.eventPrefix || "vo-",
audios = voBatch.audio;
if (Array.isArray(audios)) {
for (let x = 0; x < audios.length; x++) {
const
audio = audios[x];
createMapping(`${prefix}${audio}`, audio, voBatch);
}
} else {
Object.keys(audios).forEach((key) => createMapping(`${prefix}${key}`, audios[key], voBatch));
}
}
}
for (let i = 0; i < voMapLength; i++) {
const
key = voMapKeys[i];
audioDefinition.audioMap[key] = createVO(voiceOverMap[key], animationMap, this.message, this.frameLength);
}
Promise.all([componentInit(typeof this.renderComponent === 'string' ? platypus.components[this.renderComponent] : this.renderComponent, animationDefinition), componentInit(AudioVO, audioDefinition)]).then(callback);
return true;
},
methods: {
destroy () {
platypus.game.voPlayer.off('lipsync', this.relayLipSyncEvents);
}
},
events: {
"play-voice-over-with-lip-sync": function (vo) {
this.owner.triggerEvent('play-voice-over', createVO(vo, this.animationMap, this.message, this.frameLength));
}
},
getAssetList: function (component, props, defaultProps) {
const
ss = component?.spriteSheet ?? props?.spriteSheet ?? defaultProps?.spriteSheet,
audioMap = component?.voiceOverMap ?? props?.voiceOverMap ?? defaultProps?.voiceOverMap,
voAssets = AudioVO.getAssetList({
audioMap
});
if (typeof ss === 'string') {
return [
...greenSlice(platypus.game.settings.spriteSheets[ss].images),
...voAssets
];
} else if (ss) {
return [
...greenSlice(ss.images),
...voAssets
];
} else {
return voAssets;
}
}
});