UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

472 lines (375 loc) • 14.3 kB
import { assert } from "../../../core/assert.js"; import { weightedRandomFromArray } from "../../../core/collection/array/weightedRandomFromArray.js"; import Vector2 from "../../../core/geom/Vector2.js"; import { max2 } from "../../../core/math/max2.js"; import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js"; import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js"; import { DomSizeObserver } from "../../../view/util/DomSizeObserver.js"; import AnimationTrack from "../../animation/keyed2/AnimationTrack.js"; import AnimationTrackPlayback from "../../animation/keyed2/AnimationTrackPlayback.js"; import { AnimationBehavior } from "../../animation/keyed2/behavior/AnimationBehavior.js"; import TransitionFunctions from "../../animation/TransitionFunctions.js"; import { GameAssetType } from "../../asset/GameAssetType.js"; import { SequenceBehavior } from "../../intelligence/behavior/composite/SequenceBehavior.js"; import { BehaviorComponent } from "../../intelligence/behavior/ecs/BehaviorComponent.js"; import { DieBehavior } from "../../intelligence/behavior/ecs/DieBehavior.js"; import { ActionBehavior } from "../../intelligence/behavior/primitive/ActionBehavior.js"; import { DelayBehavior } from "../../intelligence/behavior/util/DelayBehavior.js"; import { Blackboard } from "../../intelligence/blackboard/Blackboard.js"; import { globalMetrics } from "../../metrics/GlobalMetrics.js"; import { MetricsCategory } from "../../metrics/MetricsCategory.js"; import { Attachment } from "../attachment/Attachment.js"; import { SerializationMetadata } from "../components/SerializationMetadata.js"; import Entity from "../Entity.js"; import { EntityFlags } from "../EntityFlags.js"; import GUIElement from "../gui/GUIElement.js"; import HeadsUpDisplay from "../gui/hud/HeadsUpDisplay.js"; import ViewportPosition from "../gui/position/ViewportPosition.js"; import { AbstractContextSystem } from "../system/AbstractContextSystem.js"; import { SystemEntityContext } from "../system/SystemEntityContext.js"; import { Transform } from "../transform/Transform.js"; import { SpeechBubbleView } from "./SpeechBubbleView.js"; import { Voice } from "./Voice.js"; import { VoiceEvents } from "./VoiceEvents.js"; import { VoiceFlags } from "./VoiceFlags.js"; /** * Delay before the user notices the text and begins to read * @type {number} * @ignore */ const TIMING_NOTICE_DELAY = 0.5; /** * Minimum time to read something * @type {number} * @ignore */ const TIMING_MINIMUM_READ_TIME = 1.2; /** * * @type {LineDescription[]} * @ignore */ const temp_lines = []; class LineWeigher { /** * * @type {number} */ entity = -1; /** * * @type {VoiceSystem} */ system = null; /** * * @type {number} */ time = 0; /** * * @param {LineDescription} line * @return {number} */ compute(line) { const entity = this.entity; const last_spoken = this.system.__global_last_used_times.get(line.id); let freshness_score = 0; if (last_spoken !== undefined) { const time_since_last_spoken = this.time - last_spoken; freshness_score += time_since_last_spoken; } else { // no record of the line being spoken, consider very fresh freshness_score = 10000; } return freshness_score; } } class Context extends SystemEntityContext { /** * * @type {LineDescription} */ active_line = null; /** * * @type {Entity} */ active_executor = null; handleSpeakLineEvent({ id }) { this.system.sayLine(this.entity, id, this.components[0]); } handleSpeakLineSetEvent({ id }) { this.system.sayLineFromSet(this.entity, id, this.components[0]); } link() { const dataset = this.getDataset(); dataset.addEntityEventListener(this.entity, VoiceEvents.SpeakLine, this.handleSpeakLineEvent, this); dataset.addEntityEventListener(this.entity, VoiceEvents.SpeakSetLine, this.handleSpeakLineSetEvent, this); } unlink() { const dataset = this.getDataset(); dataset.removeEntityEventListener(this.entity, VoiceEvents.SpeakLine, this.handleSpeakLineEvent, this); dataset.removeEntityEventListener(this.entity, VoiceEvents.SpeakSetLine, this.handleSpeakLineSetEvent, this); } } const SPEECH_BUBBLE_ANIMATION_INTRO = new AnimationTrack(['alpha']); SPEECH_BUBBLE_ANIMATION_INTRO.addKey(0, [0]); SPEECH_BUBBLE_ANIMATION_INTRO.addKey(0.1, [1]); SPEECH_BUBBLE_ANIMATION_INTRO.addTransition(0, TransitionFunctions.CubicEaseIn); const SPEECH_BUBBLE_ANIMATION_OUTRO = new AnimationTrack(['alpha']); SPEECH_BUBBLE_ANIMATION_OUTRO.addKey(0, [1]); SPEECH_BUBBLE_ANIMATION_OUTRO.addKey(0.2, [0]); SPEECH_BUBBLE_ANIMATION_OUTRO.addTransition(0, TransitionFunctions.CubicEaseIn); /** * @this View * @param {number} alpha * @ignore */ function bubble_animation_update_function(alpha) { this.css({ opacity: alpha }); } export class VoiceSystem extends AbstractContextSystem { /** * * @param {Engine} engine * @param settings */ constructor(engine, settings = { font: "unknown.ttf", //path to font file font_size: 16 }) { super(Context); this.dependencies = [Voice]; this.components_used = [ ResourceAccessSpecification.from(GUIElement, ResourceAccessKind.Create), ResourceAccessSpecification.from(ViewportPosition, ResourceAccessKind.Create), ResourceAccessSpecification.from(HeadsUpDisplay, ResourceAccessKind.Create), ResourceAccessSpecification.from(Transform, ResourceAccessKind.Create), ResourceAccessSpecification.from(Attachment, ResourceAccessKind.Create), ResourceAccessSpecification.from(SerializationMetadata, ResourceAccessKind.Create), ResourceAccessSpecification.from(BehaviorComponent, ResourceAccessKind.Create), ]; /** * * @type {Localization} */ this.localiation = null; /** * * @type {GMLEngine} */ this.gml = null; /** * * @type {LineDescriptionTable} */ this.lines = null; /** * * @type {LineSetDescriptionTable} */ this.sets = null; /** * * @type {Engine} */ this.engine = engine; /** * When last a line was spoken * @type {Map<string, number>} * @private */ this.__global_last_used_times = new Map(); /** * * @type {LineWeigher} * @private */ this.__weigher = new LineWeigher(); this.__weigher.system = this; /** * * @type {Font} * @private */ this.__font = null; /** * * @type {string} * @private */ this.__font_path = settings.font; /** * * @private */ this.__font_size = settings.font_size; /** * Print debug output into console * @type {boolean} * @private */ this.__debug = false; } /** * * @return {number} */ getCurrentTime() { return this.engine.ticker.clock.getElapsedTime(); } async startup(entityManager) { const engine = this.engine; this.localiation = engine.localization this.gml = engine.gui.gml; const knowledge = engine.staticKnowledge; this.lines = knowledge.getTable('voice-lines'); this.sets = knowledge.getTable('voice-line-sets'); assert.defined(this.lines, 'lines'); assert.defined(this.sets, 'sets'); const p_font_setting = engine.assetManager.promise(this.__font_path, GameAssetType.Font).then(font_asset => { this.__font = font_asset.create(); }); await p_font_setting; } /** * * @param {number} entity * @param {string} set_id * @param {Voice} voice */ sayLineFromSet(entity, set_id, voice) { /** * * @type {LineSetDescription} */ const set = this.sets.get(set_id); if (set === null) { console.warn(`Line set '${set_id}' not found in the database`); return; } const collected_count = set.collect(temp_lines, 0); this.__weigher.entity = entity; this.__weigher.time = this.getCurrentTime(); const selected_line = weightedRandomFromArray(temp_lines, Math.random, this.__weigher.compute, this.__weigher, collected_count); this.sayLine(entity, selected_line.id, voice); } /** * * @param {string} text * @param {View} view * @ignore * @private */ __setBubbleSize(text, view) { const sizer = new DomSizeObserver(); sizer.watchView(view); sizer.dimensions.size.onChanged.add((x, y) => { if (Number.isFinite(x)) { view.size.x = x; } if (Number.isFinite(y)) { view.size.y = y; } }); const advanceWidth = this.__font.getAdvanceWidth(text, this.__font_size); view.size.setSilent(advanceWidth, this.__font_size); } /** * * @param {number} entity * @param {string} line_id * @param {Voice} voice */ sayLine(entity, line_id, voice) { const ctx = this.__getEntityContext(entity); if (ctx.active_executor !== null && ctx.active_executor.getFlag(EntityFlags.Built)) { // terminate currently speech bubble ctx.active_executor.destroy(); } /** * * @type {LineDescription} */ const line = this.lines.get(line_id); if (line === null) { console.warn(`Line '${line_id}' not found in the database`); return; } // record when the line was spoken this.__global_last_used_times.set(line_id, this.getCurrentTime()); const ecd = this.entityManager.dataset; const localiation = this.localiation; const localization_key = line.text; const localized_line = localiation.getString(localization_key); const view = new SpeechBubbleView(); const gml = this.gml; gml.compile(localized_line, view); // localized line may contain reference tags, the user will not see/read those, so we also compile line as pure text for estimating reading time const line_pure_text = gml.compileAsText(localized_line); const display_time_raw = localiation.estimateReadingTime(line_pure_text); const display_time = max2(TIMING_MINIMUM_READ_TIME, display_time_raw * line.displayDuration) + TIMING_NOTICE_DELAY; if (this.__debug) { console.log('Display time:', display_time, line_pure_text); } voice.setFlag(VoiceFlags.Speaking); ecd.sendEvent(entity, VoiceEvents.StartedSpeakingLocalizedLine, { text: localized_line, key: localization_key }); const transform = new Transform(); // copy transform from source entity const source_transform = ecd.getComponent(entity, Transform); if (source_transform !== undefined) { transform.copy(source_transform); } this.__setBubbleSize(line_pure_text, view); const entityBuilder = new Entity() .add(GUIElement.fromView(view)) .add(ViewportPosition.fromJSON({ anchor: new Vector2(0.5, 1) })) .add(HeadsUpDisplay.fromJSON({})) .add(transform) .add(Attachment.fromJSON({ socket: 'Voice', parent: entity, immediate: true })) .add(SerializationMetadata.Transient) .add(BehaviorComponent.from(SequenceBehavior.from([ // play intro animation new AnimationBehavior(new AnimationTrackPlayback(SPEECH_BUBBLE_ANIMATION_INTRO, bubble_animation_update_function, view)), // wait for a certain amount of time DelayBehavior.from(display_time), // dispatch event and record that like was spoken new ActionBehavior(() => { // clear speaking flag voice.clearFlag(VoiceFlags.Speaking); if (ecd.entityExists(entity)) { // notify that the line has ended ecd.sendEvent(entity, VoiceEvents.FinishedSpeakingLine, line_id); // record the fact that line was spoken const bb = ecd.getComponent(entity, Blackboard); if (bb !== undefined) { bb.incrementNumber(`voice.line_spoken.${line_id}.count`); } } }), //play outro animation new AnimationBehavior(new AnimationTrackPlayback(SPEECH_BUBBLE_ANIMATION_OUTRO, bubble_animation_update_function, view)), // destroy the entity DieBehavior.create() ]))); ctx.active_line = line; ctx.active_executor = entityBuilder; entityBuilder .build(ecd); // send metrics globalMetrics.record('ecs.system.voice', { category: MetricsCategory.System, label: line_id }); } }