prendy
Version:
Make games with prerendered backdrops using babylonjs and repond
218 lines (189 loc) • 9.1 kB
text/typescript
import { CSSProperties } from "react";
import { getState, onNextTick, setState, startNewItemEffect, stopNewEffect } from "repond";
import { II, chainDo, makeEventTypes, runEvents, setLiveEventState } from "repond-events";
import { length } from "stringz";
import { getCharDollStuff } from "../helpers/prendyUtils/characters";
import { getGlobalState, setGlobalState } from "../helpers/prendyUtils/global";
import { getTypingDelayForText } from "../helpers/prendyUtils/speechBubbles";
import { meta } from "../meta";
import { CharacterName, DollName, SpeechBubbleName } from "../types";
const RESET_CAMERA_FOCUS_CHAIN_ID = "resetCameraFocus";
const HIDE_TITLE_TEXT_CHAIN_ID = "hideAlarmText";
const getCloseCharacterSpeechBubbleChainId = (character: CharacterName) => `closeSpeechBubble_${character}`;
const getCloseCharacterMiniBubbleChainId = (character: CharacterName) => `closeMiniBubble_${character}`;
const speechRefs = {
shownTextBools: {} as Record<string, boolean>, // { ["hello"] : true }
aSpeechIsShowing: false, // NOTE probably better as global state or refs
originalZoomAmount: 1,
originalFocusedDoll: null as null | DollName,
};
const SPEECH_ZOOM_AMOUNT = 1.2;
const SPEECH_CLOSE_DELAY = 700; // close if no more messages from the character after 1this time
const MIN_AUTO_SPEECH_TIME = 1500;
export const speechEvents = makeEventTypes(({ event }) => ({
say: event({
run: async ({ what: text, ...options }, { runMode, liveId, isFirstAdd, elapsedTime }) => {
const playerCharacter = getGlobalState().playerCharacter as CharacterName;
const {
time, // time = 2600,
showOnce = false,
who = playerCharacter,
zoomAmount = SPEECH_ZOOM_AMOUNT,
returnToZoomBeforeConversation: returnToZoomBeforeConversation = false,
stylesBySpecialText,
} = options ?? {};
const closeCharacterSpeechChainId = getCloseCharacterSpeechBubbleChainId(who);
const progressButtonEffectId = "say_handlePressButton" + liveId;
if (runMode === "end") {
stopNewEffect(progressButtonEffectId);
chainDo("cancel", closeCharacterSpeechChainId);
chainDo("cancel", RESET_CAMERA_FOCUS_CHAIN_ID);
runEvents([II("basic", "wait", { time: SPEECH_CLOSE_DELAY / 1000 }), II("speech", "hideSay", { who })], {
chainId: closeCharacterSpeechChainId,
});
runEvents(
[
II("basic", "wait", { time: SPEECH_CLOSE_DELAY / 1000 }),
II("speech", "resetCameraFocus", { who, returnToZoomBeforeConversation }),
],
{ chainId: RESET_CAMERA_FOCUS_CHAIN_ID }
);
}
if (runMode !== "start") return;
const endEvent = () => setState({ liveEvents: { [liveId]: { goalEndTime: 0 } } });
if (showOnce && speechRefs.shownTextBools[text]) endEvent();
const { prendyOptions } = meta.assets!;
const { slateZoom: initialSlateZoom } = getGlobalState();
const { dollName } = getCharDollStuff(who);
const timeInMilliseconds = time ? time * 1000 : undefined;
// NOTE at the moment CharacterName and SpeechBubbleName are the same
const timeBasedOnText = MIN_AUTO_SPEECH_TIME + getTypingDelayForText(text, who as any) * 2;
const waitTime = timeInMilliseconds ?? timeBasedOnText;
setLiveEventState(liveId, { goalEndTime: elapsedTime + waitTime });
function handlePressProgressButton() {
const speechBubbleState = getState().speechBubbles[who];
const { typingFinished, goalText } = speechBubbleState;
if (!typingFinished) {
// Finish typing
setState({ speechBubbles: { [who]: { visibleLetterAmount: length(goalText) } } });
// Finished reading
} else endEvent();
}
// on next tick so it doesnt react to the first press that shows the speech bubble
onNextTick(() => {
startNewItemEffect({
id: progressButtonEffectId,
run: handlePressProgressButton,
check: { type: "players", prop: "interactButtonPressTime" },
});
});
chainDo("cancel", closeCharacterSpeechChainId);
chainDo("cancel", RESET_CAMERA_FOCUS_CHAIN_ID);
if (!speechRefs.aSpeechIsShowing) {
speechRefs.originalZoomAmount = initialSlateZoom;
speechRefs.aSpeechIsShowing = true;
}
const newSlateZoom = Math.min(speechRefs.originalZoomAmount * zoomAmount, prendyOptions.zoomLevels.max);
onNextTick(() => {
setState({
speechBubbles: {
[who]: {
isVisible: true,
goalText: text,
stylesBySpecialText,
visibleLetterAmount: 0,
typingFinished: false, // NOTE could make this automatic with an effect
},
},
global: { main: { focusedDoll: dollName, slateZoomGoal: newSlateZoom + Math.random() * 0.001 } },
});
});
speechRefs.shownTextBools[text] = true;
},
params: {
what: "",
time: undefined as number | undefined,
showOnce: undefined as boolean | undefined,
who: undefined as undefined | (SpeechBubbleName & CharacterName), // NOTE SpeechBubble names and CharacterNames match at the moment
zoomAmount: undefined as number | undefined,
lookAtPlayer: undefined as boolean | undefined,
returnToZoomBeforeConversation: undefined as boolean | undefined, // remembers the previous zoom instead of going to the default when the convo ends
// TODO rename stylesByKeyword or keywordStyles
stylesBySpecialText: undefined as Record<string, CSSProperties> | undefined, // { "golden banana": { color: "yellow" } } // style snippets of text
},
duration: Infinity,
}),
hideSay: event({
run: ({ who }, { runMode }) => {
if (runMode !== "start") return;
setState({ speechBubbles: { [who]: { isVisible: false } } });
},
params: { who: "" as CharacterName },
}),
resetCameraFocus: event({
run: ({ who, returnToZoomBeforeConversation = false }, { runMode }) => {
if (runMode !== "start") return;
const { prendyOptions } = meta.assets!;
const playerCharacter = getGlobalState().playerCharacter as CharacterName;
const { dollName } = getCharDollStuff(who);
const { dollName: playerDollName } = getCharDollStuff(playerCharacter);
speechRefs.aSpeechIsShowing = false;
const currentFocusedDoll = getGlobalState().focusedDoll;
const isFocusedOnTalkingCharacter = currentFocusedDoll === dollName;
// NOTE safer to use the setState((state)=> {}) callback to check the current focused doll
setGlobalState({
focusedDoll: isFocusedOnTalkingCharacter ? playerDollName : currentFocusedDoll,
slateZoomGoal: returnToZoomBeforeConversation
? speechRefs.originalZoomAmount
: prendyOptions.zoomLevels.default,
});
},
params: { who: "" as CharacterName, returnToZoomBeforeConversation: undefined as boolean | undefined },
}),
think: event({
run: ({ what: text, who, time = 100 }, { runMode }) => {
if (runMode !== "start") return;
const { playerCharacter } = getState().global.main;
const character = who || playerCharacter;
const closeCharacterMiniBubbleChainId = getCloseCharacterMiniBubbleChainId(playerCharacter);
setState({ miniBubbles: { [character]: { isVisible: true, text } } });
// 10 second timeout incase the hideMiniBubble() didn't run, like from leaving a trigger
chainDo("cancel", closeCharacterMiniBubbleChainId);
runEvents([II("basic", "wait", { time }), II("speech", "hideMiniBubble", {})], {
chainId: closeCharacterMiniBubbleChainId,
});
},
params: { what: "", who: undefined as CharacterName | undefined, time: undefined as number | undefined },
}),
hideThink: event({
run: ({ who }, { runMode }) => {
if (runMode !== "start") return;
const { playerCharacter } = getState().global.main;
const character = who || playerCharacter;
setState({ miniBubbles: { [character]: { isVisible: false } } });
},
params: { who: undefined as CharacterName | undefined },
}),
showTitle: event({
run: async ({ text, time = 1 }, { runMode, liveId, elapsedTime }) => {
if (runMode !== "start") return;
// NOTE alarm text in 'global' instead of project-specific 'story' ?
setGlobalState({ alarmText: text, alarmTextIsVisible: true });
const timeInMilliseconds = time * 1000;
setLiveEventState(liveId, { goalEndTime: elapsedTime + timeInMilliseconds });
// Set it to close the next one
chainDo("cancel", HIDE_TITLE_TEXT_CHAIN_ID);
runEvents([II("basic", "wait", { time }), II("speech", "hideTitle", {})], {
chainId: HIDE_TITLE_TEXT_CHAIN_ID,
});
},
params: { text: "", time: undefined as number | undefined },
}),
hideTitle: event({
run: (_, { runMode }) => {
if (runMode !== "start") return;
setGlobalState({ alarmTextIsVisible: false });
},
params: {},
}),
}));