@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
436 lines (333 loc) • 12.3 kB
JavaScript
import { assert } from "../../core/assert.js";
import Vector2 from "../../core/geom/Vector2.js";
import { makeCubicCurve } from "../../core/math/spline/makeCubicCurve.js";
import ObservedBoolean from "../../core/model/ObservedBoolean.js";
import { ReactiveAnd } from "../../core/model/reactive/model/logic/ReactiveAnd.js";
import { ReactiveReference } from "../../core/model/reactive/model/terminal/ReactiveReference.js";
import { AchievementNotificationView } from "../../view/game/achievements/AchievementNotificationView.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 { SerializationMetadata } from "../ecs/components/SerializationMetadata.js";
import Entity from "../ecs/Entity.js";
import GUIElement from "../ecs/gui/GUIElement.js";
import ViewportPosition from "../ecs/gui/position/ViewportPosition.js";
import { Transform } from "../ecs/transform/Transform.js";
import { SequenceBehavior } from "../intelligence/behavior/composite/SequenceBehavior.js";
import { BehaviorComponent } from "../intelligence/behavior/ecs/BehaviorComponent.js";
import { ClockChannelType } from "../intelligence/behavior/ecs/ClockChannelType.js";
import { DieBehavior } from "../intelligence/behavior/ecs/DieBehavior.js";
import { logger } from "../logging/GlobalLogger.js";
import { globalMetrics } from "../metrics/GlobalMetrics.js";
import { MetricsCategory } from "../metrics/MetricsCategory.js";
import { EnginePlugin } from "../plugin/EnginePlugin.js";
import { SoundEmitter } from "../sound/ecs/emitter/SoundEmitter.js";
import { SoundEmitterChannels } from "../sound/ecs/emitter/SoundEmitterSystem.js";
import { Achievement } from "./Achievement.js";
const SLOW_CUBIC = makeCubicCurve(0.04, 0.4, 0.9, 0.99);
const OVERSHOT_CUBIC_0 = makeCubicCurve(0.04, 0.4, 1.8, 0.99);
/**
*
* @param {String} id
* @returns {string}
*/
function computeAchievementBlackboardFlag(id) {
return `system.achievement.${id}.completed`;
}
export class AchievementManager extends EnginePlugin {
constructor() {
super();
this.id = 'achievements';
/**
*
* @type {AchievementGateway|null}
*/
this.gateway = null;
/**
*
* @type {Achievement[]}
*/
this.entries = [];
/**
*
* @type {AssetManager|null}
*/
this.assetManager = null;
/**
*
* @type {Blackboard|null}
*/
this.blackboard = null;
/**
*
* @type {EntityManager|null}
*/
this.entityManager = null;
/**
*
* @type {Localization|null}
*/
this.localization = null;
/**
* @readonly
* @type {ObservedBoolean}
*/
this.isStarted = new ObservedBoolean(false);
/**
* @readonly
* @type {ObservedBoolean}
*/
this.isGatewayInitialized = new ObservedBoolean(false);
/**
* @readonly
* @type {ObservedBoolean}
*/
this.isBlackboardAttached = new ObservedBoolean(false);
/**
* @readonly
* @type {ReactiveAnd}
*/
this.isActive = ReactiveAnd.from(
ReactiveReference.from(this.isStarted, 'started'),
ReactiveReference.from(this.isBlackboardAttached, 'blackboardAttached')
);
this.isActive.onChanged.add(v => {
if (v) {
this.activate();
} else {
this.deactivate();
}
});
this.handlers = {};
}
/**
*
* @param {String} id
* @return {Achievement|undefined}
*/
getAchievementById(id) {
return this.entries.find(a => a.id === id);
}
/**
*
* @param {String} id
*/
unlock(id) {
assert.isString(id, 'id');
const key = computeAchievementBlackboardFlag(id);
const value = this.blackboard.acquireBoolean(key, false);
value.set(true);
//release value
this.blackboard.release(key);
this.gateway.unlock(id);
const achievement = this.getAchievementById(id);
if (achievement === undefined) {
logger.warn(`Attempting to unlock achievement ${id}. Achievement not found`);
}
if (achievement !== undefined && this.isGatewayInitialized.getValue()) {
this.deactivateEntry(achievement);
this.present(achievement);
}
globalMetrics.record("achievement", {
category: MetricsCategory.Progression,
label: id
});
}
/**
* @private
* @param {Achievement} entry
*/
activateEntry(entry) {
entry.trigger.link(this.blackboard);
const handler = (v) => {
if (v) {
this.unlock(entry.id);
}
};
this.handlers[entry.id] = handler;
entry.trigger.getExpression()
.process(handler);
}
/**
* @private
* @param {Achievement} entry
*/
deactivateEntry(entry) {
entry.trigger.unlink();
const handler = this.handlers[entry.id];
if (handler !== undefined) {
entry.trigger.getExpression()
.onChanged.remove(handler);
}
}
/**
* @private
*/
activate() {
this.entries.forEach(a => {
if (!a.enabled) {
//ignore
return;
}
this.activateEntry(a);
});
}
/**
* @private
*/
deactivate() {
this.entries.forEach(a => {
this.deactivateEntry(a);
});
}
async initialize(engine) {
this.assetManager = engine.assetManager;
this.gateway = engine.platform.getAchievementGateway();
this.entityManager = engine.entityManager;
this.localization = engine.localization;
}
/**
*
* @param {AssetManager} assetManager
*/
async loadDefinitions(assetManager) {
const asset = await assetManager.promise("data/database/achievements/data.json", GameAssetType.JSON)
const json = asset.create();
json.forEach(def => {
const achievement = new Achievement();
achievement.fromJSON(def);
this.entries.push(achievement);
});
}
/**
* Visually present an achievement
* @param {Achievement} achievement
*/
present(achievement) {
const ecd = this.entityManager.dataset;
if (ecd === null) {
//no ECD, skip
return;
}
const localization = this.localization;
const achievementView = new AchievementNotificationView({ achievement, localization });
achievementView.size.x = 460;
achievementView.size.y = 58;
const viewportPosition = ViewportPosition.fromJSON({
position: new Vector2(0.5, 0)
});
viewportPosition.anchor.set(0.5, 0.5);
const aEntry = new AnimationTrack(['scale', 'alpha', 'v']);
aEntry.addKey(0, [1.3, 0.2, 0]);
aEntry.addKey(0.4, [1, 1, 0.8]);
aEntry.addTransition(0, OVERSHOT_CUBIC_0);
const aMain = new AnimationTrack(["value"]);
aMain.addKey(0, [0]);
aMain.addKey(8.3, [1]);
aMain.addTransition(0, TransitionFunctions.Linear);
const aExit = new AnimationTrack(['scale', 'alpha', 'v']);
aExit.addKey(0, [1, 1, 0]);
aExit.addKey(1, [1, 0, 1]);
aExit.addTransition(0, SLOW_CUBIC);
const builder = new Entity();
const sequenceBehavior = SequenceBehavior.from([
new AnimationBehavior(new AnimationTrackPlayback(aEntry, (scale, alpha, v) => {
achievementView.scale.setScalar(scale);
achievementView.css({
opacity: alpha
});
})),
new AnimationBehavior(new AnimationTrackPlayback(aMain, (value) => {
})),
new AnimationBehavior(new AnimationTrackPlayback(aExit, (scale, alpha, v) => {
achievementView.scale.setScalar(scale);
achievementView.css({
opacity: alpha
});
})),
DieBehavior.create()
]);
const cBehavior = BehaviorComponent.from(sequenceBehavior);
//use system clock for behavior
cBehavior.clock = ClockChannelType.Simulation;
const guiElement = GUIElement.fromView(achievementView);
guiElement.group = "ui-managed-achievements";
const soundEmitter = SoundEmitter.fromJSON({
tracks: [
{
url: "data/sounds/effects/Magic_Game_Essentials/Magic_Airy_Alert.wav",
startWhenReady: true
}
],
isPositioned: false,
volume: 1,
loop: false,
channel: SoundEmitterChannels.Effects
});
//prevent achievement message from being serialized in game save
builder
.add(SerializationMetadata.Transient)
.add(new Transform())
.add(soundEmitter)
.add(cBehavior)
.add(guiElement)
.add(viewportPosition)
.build(ecd);
}
/**
*
* @param {Blackboard} blackboard
*/
attachBlackboard(blackboard) {
if (this.blackboard !== null) {
throw new Error('Blackboard already attached');
}
this.blackboard = blackboard;
this.isBlackboardAttached.set(true);
}
async initializeGateway() {
/**
*
* @type {string[]}
*/
const unlockedIds = await this.gateway.getUnlocked();
//de-activate unlocked achievements
const unlockedAchievements = this.entries.filter(a => unlockedIds.includes(a.id));
unlockedAchievements.forEach(a => a.enabled = false);
//try to read unlocked achievements from blackboard
if (this.isBlackboardAttached.getValue()) {
this.entries.forEach(achievement => {
const id = achievement.id;
const blackboardKey = computeAchievementBlackboardFlag(id);
const blackboardValue = this.blackboard.acquireBoolean(blackboardKey, false);
if (!unlockedIds.includes(id)) {
// gateway doesn't have this achievement unlocked yet
// check blackboard
if (blackboardValue.getValue()) {
// unlocked in the blackboard, communicate to the gateway
this.gateway.unlock(id);
//disable achievement tracking
achievement.enabled = false;
}
} else {
//not marker as unlocked on gateway
this.deactivateEntry(achievement);
}
//release the value
this.blackboard.release(blackboardKey);
});
}
this.isGatewayInitialized.set(true);
}
async startup() {
assert.equal()
//load achievement definitions
await this.loadDefinitions(this.assetManager);
this.initializeGateway();
this.isStarted.set(true);
}
async shutdown() {
}
}