diff --git a/src/assets/stylesheets/chat-command-help.scss b/src/assets/stylesheets/chat-command-help.scss new file mode 100644 index 0000000000000000000000000000000000000000..c8f0b42a5eedcbeaff94f06065cd87676b6f2aad --- /dev/null +++ b/src/assets/stylesheets/chat-command-help.scss @@ -0,0 +1,30 @@ +@import 'shared.scss'; + +:local(.command-help) { + background-color: $darker-grey; + color: $light-text; + position: absolute; + display: flex; + flex-direction: column; + width: 75%; + left: 0; + bottom: 58px; + pointer-events: auto; + padding: 8px 1.25em; + border-radius: 16px; + font-size: 0.8em; + + :local(.entry) { + @extend %default-font; + margin: 4px; + + display: flex; + justify-content: space-between; + + :local(.command) { + font-weight: bold; + white-space: nowrap; + margin-right: 8px; + } + } +} diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 67ec0078c9d052d4d504053ee6dcd973eb6b7a45..61851b825eecc3710f30f7ba0524c3ca6584839e 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -51,6 +51,7 @@ "audio.granted-next": "Next", "exit.subtitle.exited": "Your session has ended. Refresh your browser to start a new one.", "exit.subtitle.closed": "This room is no longer available.", + "exit.subtitle.left": "You have left the room.", "exit.subtitle.full": "This room is full, please try again later.", "exit.subtitle.connect_error": "Unable to connect to this room, please try again later.", "exit.subtitle.version_mismatch": "The version you deployed is not available yet. Your browser will refresh in 5 seconds.", @@ -111,6 +112,12 @@ "spoke.download_unsupported": "View Releases", "spoke.browse_all_versions": "Browse All Versions", "spoke.close": "Close", - "spoke.play_button": "Learn Spoke in 5 Minutes" + "spoke.play_button": "Learn Spoke in 5 Minutes", + "commands.fly": "Toggle fly mode.", + "commands.bigger": "Increase your avatar's size.", + "commands.smaller": "Decrease your avatar's size.", + "commands.help": "Show help.", + "commands.leave": "Disconnect from the room.", + "commands.duck": "The duck tested well. Quack." } } diff --git a/src/components/character-controller.js b/src/components/character-controller.js index 083f47e195e7453136c958ecad62762be882c614..6eb743ec7ba515f19828a9709f44e8f1307f0455 100644 --- a/src/components/character-controller.js +++ b/src/components/character-controller.js @@ -3,6 +3,7 @@ const CLAMP_VELOCITY = 0.01; const MAX_DELTA = 0.2; const EPS = 10e-6; const MAX_WARNINGS = 10; +const PI_2 = Math.PI / 2; /** * Avatar movement controller that listens to move, rotate and teleportation events and moves the avatar accordingly. @@ -16,7 +17,8 @@ AFRAME.registerComponent("character-controller", { easing: { default: 10 }, pivot: { type: "selector" }, snapRotationDegrees: { default: THREE.Math.DEG2RAD * 45 }, - rotationSpeed: { default: -3 } + rotationSpeed: { default: -3 }, + fly: { default: false } }, init: function() { @@ -140,7 +142,7 @@ AFRAME.registerComponent("character-controller", { rotationInvMatrix.makeRotationAxis(rotationAxis, -root.rotation.y); pivotRotationMatrix.makeRotationAxis(rotationAxis, pivot.rotation.y); pivotRotationInvMatrix.makeRotationAxis(rotationAxis, -pivot.rotation.y); - this.updateVelocity(deltaSeconds); + this.updateVelocity(deltaSeconds, pivot); this.accelerationInput.set(0, 0, 0); const boost = userinput.get(paths.actions.boost) ? 2 : 1; @@ -178,7 +180,7 @@ AFRAME.registerComponent("character-controller", { this.pendingSnapRotationMatrix.identity(); // Revert to identity - if (this.velocity.lengthSq() > EPS) { + if (this.velocity.lengthSq() > EPS && !this.data.fly) { this.setPositionOnNavMesh(startPos, root.position, root); } }; @@ -221,13 +223,14 @@ AFRAME.registerComponent("character-controller", { pathfinder.clampStep(position, navPosition, this.navNode, this.navZone, this.navGroup, object3D.position); }, - updateVelocity: function(dt) { + updateVelocity: function(dt, pivot) { const data = this.data; const velocity = this.velocity; // If FPS too low, reset velocity. if (dt > MAX_DELTA) { velocity.x = 0; + velocity.y = 0; velocity.z = 0; return; } @@ -236,17 +239,24 @@ AFRAME.registerComponent("character-controller", { if (velocity.x !== 0) { velocity.x -= velocity.x * data.easing * dt; } - if (velocity.z !== 0) { - velocity.z -= velocity.z * data.easing * dt; - } if (velocity.y !== 0) { velocity.y -= velocity.y * data.easing * dt; } + if (velocity.z !== 0) { + velocity.z -= velocity.z * data.easing * dt; + } const dvx = data.groundAcc * dt * this.accelerationInput.x; const dvz = data.groundAcc * dt * -this.accelerationInput.z; velocity.x += dvx; - velocity.z += dvz; + + if (this.data.fly) { + const pitch = pivot.rotation.x / PI_2; + velocity.y += dvz * -pitch; + velocity.z += dvz * (1.0 - pitch); + } else { + velocity.z += dvz; + } const decay = 0.7; this.accelerationInput.x = this.accelerationInput.x * decay; @@ -255,7 +265,7 @@ AFRAME.registerComponent("character-controller", { if (Math.abs(velocity.x) < CLAMP_VELOCITY) { velocity.x = 0; } - if (Math.abs(velocity.y) < CLAMP_VELOCITY) { + if (this.data.fly && Math.abs(velocity.y) < CLAMP_VELOCITY) { velocity.y = 0; } if (Math.abs(velocity.z) < CLAMP_VELOCITY) { diff --git a/src/hub.html b/src/hub.html index 9ca50bc75c84d4fa84cf0c871bfef86969dba7f6..8905bed8c22d74cd847f8dbd65663c1fd0030f72 100644 --- a/src/hub.html +++ b/src/hub.html @@ -68,9 +68,10 @@ <a-asset-item id="quack" src="./assets/sfx/quack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="specialquack" src="./assets/sfx/specialquack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item> + <a-asset-item id="sound_asset-quack" src="./assets/sfx/quack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-teleport_start" src="./assets/sfx/teleportArc.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-teleport_end" src="./assets/sfx/quickTurn.wav" response-type="arraybuffer" preload="auto"></a-asset-item> - <a-asset-item id="sound_asset-snap_rotate" src="./assets/sfx/quickTurn.wav" response-type="arraybuffer" preload="auto"></a-asset-item> + <a-asset-item id="sound_asset-snap_rotate" src="./assets/sfx/tap_mellow.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-media_loaded" src="./assets/sfx/A_bendUp.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-media_loading" src="./assets/sfx/suspense.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-hud_hover_start" src="./assets/sfx/tick.wav" response-type="arraybuffer" preload="auto"></a-asset-item> @@ -94,6 +95,7 @@ <a-asset-item id="sound_asset-stop_draw" src="./assets/sfx/tick.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-camera_tool_took_snapshot" src="./assets/sfx/PicSnapHey.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <a-asset-item id="sound_asset-welcome" src="./assets/sfx/welcome.wav" response-type="arraybuffer" preload="auto"></a-asset-item> + <a-asset-item id="sound_asset-chat" src="./assets/sfx/pop.wav" response-type="arraybuffer" preload="auto"></a-asset-item> <!-- Templates --> <template id="video-template"> @@ -480,6 +482,10 @@ scene-sound__camera_tool_took_snapshot="on: camera_tool_took_snapshot;" sound__welcome="positional: false; src: #sound_asset-welcome; on: nothing; poolSize: 2;" scene-sound__welcome="on: entered;" + sound__log_chat_message="positional: false; src: #sound_asset-chat; on: nothing; poolSize: 2;" + scene-sound__log_chat_message="on: presence-log-chat;" + sound__quack="positional: false; src: #sound_asset-quack; on: nothing; poolSize: 2;" + scene-sound__quack="on: quack;" > <a-entity id="gaze-teleport" diff --git a/src/hub.js b/src/hub.js index f08f7d3b60c8bf73317435718710998ac3a0d33f..2cd48aee5207f47666c4723547be11047d0a126d 100644 --- a/src/hub.js +++ b/src/hub.js @@ -77,6 +77,7 @@ import LinkChannel from "./utils/link-channel"; import { connectToReticulum } from "./utils/phoenix-utils"; import { disableiOSZoom } from "./utils/disable-ios-zoom"; import { proxiedUrlFor } from "./utils/media-utils"; +import MessageDispatch from "./message-dispatch"; import SceneEntryManager from "./scene-entry-manager"; import Subscriptions from "./subscriptions"; @@ -213,7 +214,7 @@ function remountUI(props) { mountUI(uiProps); } -async function handleHubChannelJoined(entryManager, hubChannel, data) { +async function handleHubChannelJoined(entryManager, hubChannel, messageDispatch, data) { const scene = document.querySelector("a-scene"); if (NAF.connection.isConnected()) { @@ -251,7 +252,7 @@ async function handleHubChannelJoined(entryManager, hubChannel, data) { hubId: hub.hub_id, hubName: hub.name, hubEntryCode: hub.entry_code, - onSendMessage: hubChannel.sendMessage + onSendMessage: messageDispatch.dispatch }); document @@ -431,32 +432,13 @@ document.addEventListener("DOMContentLoaded", async () => { const joinPayload = { profile: store.state.profile, push_subscription_endpoint: pushSubscriptionEndpoint, context }; const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload); - hubPhxChannel - .join() - .receive("ok", async data => { - hubChannel.setPhoenixChannel(hubPhxChannel); - subscriptions.setHubChannel(hubChannel); - subscriptions.setSubscribed(data.subscriptions.web_push); - remountUI({ initialIsSubscribed: subscriptions.isSubscribed() }); - await handleHubChannelJoined(entryManager, hubChannel, data); - }) - .receive("error", res => { - if (res.reason === "closed") { - entryManager.exitScene(); - remountUI({ roomUnavailableReason: "closed" }); - } - - console.error(res); - }); - - const hubPhxPresence = new Presence(hubPhxChannel); const presenceLogEntries = []; - const addToPresenceLog = entry => { entry.key = Date.now().toString(); presenceLogEntries.push(entry); remountUI({ presenceLogEntries }); + scene.emit(`presence-log-${entry.type}`); // Fade out and then remove setTimeout(() => { @@ -470,6 +452,28 @@ document.addEventListener("DOMContentLoaded", async () => { }, 20000); }; + const messageDispatch = new MessageDispatch(scene, entryManager, hubChannel, addToPresenceLog, remountUI); + + hubPhxChannel + .join() + .receive("ok", async data => { + hubChannel.setPhoenixChannel(hubPhxChannel); + subscriptions.setHubChannel(hubChannel); + subscriptions.setSubscribed(data.subscriptions.web_push); + remountUI({ initialIsSubscribed: subscriptions.isSubscribed() }); + await handleHubChannelJoined(entryManager, hubChannel, messageDispatch, data); + }) + .receive("error", res => { + if (res.reason === "closed") { + entryManager.exitScene(); + remountUI({ roomUnavailableReason: "closed" }); + } + + console.error(res); + }); + + const hubPhxPresence = new Presence(hubPhxChannel); + let isInitialSync = true; hubPhxPresence.onSync(() => { diff --git a/src/message-dispatch.js b/src/message-dispatch.js new file mode 100644 index 0000000000000000000000000000000000000000..83babfd2bf7ff3faf2824beb5ac14d9ad96d4a1c --- /dev/null +++ b/src/message-dispatch.js @@ -0,0 +1,80 @@ +import { spawnChatMessage } from "./react-components/chat-message"; +const DUCK_URL = "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf"; + +// Handles user-entered messages +export default class MessageDispatch { + constructor(scene, entryManager, hubChannel, addToPresenceLog, remountUI) { + this.scene = scene; + this.entryManager = entryManager; + this.hubChannel = hubChannel; + this.addToPresenceLog = addToPresenceLog; + this.remountUI = remountUI; + } + + dispatch = message => { + if (message.startsWith("/")) { + this.dispatchCommand(message.substring(1)); + document.activeElement.blur(); // Commands should blur + } else { + this.hubChannel.sendMessage(message); + } + }; + + dispatchCommand = command => { + const entered = this.scene.is("entered"); + + switch (command) { + case "help": + // HACK for now, non-trivial to properly send this into React + document.querySelector(".help-button").click(); + return; + } + + if (!entered) { + this.addToPresenceLog({ type: "log", body: "You must enter the room to use this command." }); + return; + } + + const playerRig = document.querySelector("#player-rig"); + const scales = [0.0625, 0.125, 0.25, 0.5, 1.0, 1.5, 3, 5, 7.5, 12.5]; + const curScale = playerRig.object3D.scale; + + switch (command) { + case "fly": + if (playerRig.getAttribute("character-controller").fly !== true) { + playerRig.setAttribute("character-controller", "fly", true); + this.addToPresenceLog({ type: "log", body: "Fly mode enabled." }); + } else { + playerRig.setAttribute("character-controller", "fly", false); + this.addToPresenceLog({ type: "log", body: "Fly mode disabled." }); + } + break; + case "bigger": + for (let i = 0; i < scales.length; i++) { + if (scales[i] > curScale.x) { + playerRig.object3D.scale.set(scales[i], scales[i], scales[i]); + break; + } + } + + break; + case "smaller": + for (let i = scales.length - 1; i >= 0; i--) { + if (curScale.x > scales[i]) { + playerRig.object3D.scale.set(scales[i], scales[i], scales[i]); + break; + } + } + + break; + case "leave": + this.entryManager.exitScene(); + this.remountUI({ roomUnavailableReason: "left" }); + break; + case "duck": + spawnChatMessage(DUCK_URL); + this.scene.emit("quack"); + break; + } + }; +} diff --git a/src/react-components/chat-command-help.js b/src/react-components/chat-command-help.js new file mode 100644 index 0000000000000000000000000000000000000000..53dfb7b1929f70f3191a6b53c0a0265cbea1bef5 --- /dev/null +++ b/src/react-components/chat-command-help.js @@ -0,0 +1,30 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import styles from "../assets/stylesheets/chat-command-help.scss"; +import { FormattedMessage } from "react-intl"; + +export default class ChatCommandHelp extends Component { + static propTypes = { + matchingPrefix: PropTypes.string + }; + + render() { + const commands = ["help", "leave", "fly", "bigger", "smaller", "duck"]; + + return ( + <div className={styles.commandHelp}> + {commands.map( + c => + (this.props.matchingPrefix === "" || c.startsWith(this.props.matchingPrefix)) && ( + <div className={styles.entry}> + <div className={styles.command}>/{c}</div> + <div> + <FormattedMessage id={`commands.${c}`} /> + </div> + </div> + ) + )} + </div> + ); + } +} diff --git a/src/react-components/help-dialog.js b/src/react-components/help-dialog.js index 0e20c0aa98499e87f8e9fa4e52858d610d183f7f..d81b237045238396cd06f891156b9a7c0fa95875 100644 --- a/src/react-components/help-dialog.js +++ b/src/react-components/help-dialog.js @@ -19,8 +19,8 @@ export default class HelpDialog extends Component { </p> <p>When in a room, other avatars can see and hear you.</p> <p> - Use your controller's action button to teleport from place to place. If it has a trigger, use it to - pick up objects. + Use your controller's action button to teleport from place to place. If it has a grip, use it to pick + up objects. </p> <p> In VR, <b>look up</b> to find your menu. @@ -29,7 +29,7 @@ export default class HelpDialog extends Component { The <b>Mic Toggle</b> mutes your mic. </p> <p> - The <b>Pause/Resume Toggle</b> pauses all other avatars and lets you block others or remove objects. + The <b>Pause Toggle</b> pauses all other avatars and lets you block others or pin or remove objects. </p> <p className="dialog__box__contents__links"> <WithHoverSound> diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js index 4426ac46e1e86d22e82b01de4f59152bb8b2184f..7eabb323aadd38e5eb3d37d5a669582a9f5699fc 100644 --- a/src/react-components/presence-log.js +++ b/src/react-components/presence-log.js @@ -56,7 +56,7 @@ export default class PresenceLog extends Component { maySpawn={e.maySpawn} /> ); - case "spawn": { + case "spawn": return ( <PhotoMessage key={e.key} @@ -67,7 +67,12 @@ export default class PresenceLog extends Component { hubId={this.props.hubId} /> ); - } + case "log": + return ( + <div key={e.key} className={classNames(entryClasses)}> + {e.body} + </div> + ); } }; diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 77b0dfba4310c7986f9e4775cc38aa35c2449e8b..07a85c470178b2f04c270360b039fed303fc2be1 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -31,6 +31,7 @@ import CreateObjectDialog from "./create-object-dialog.js"; import PresenceLog from "./presence-log.js"; import PresenceList from "./presence-list.js"; import TwoDHUD from "./2d-hud"; +import ChatCommandHelp from "./chat-command-help"; import { spawnChatMessage } from "./chat-message"; import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers"; @@ -679,12 +680,12 @@ class UIRoot extends Component { <div> <FormattedMessage id={exitSubtitleId} /> <p /> - {this.props.roomUnavailableReason && ( + {this.props.roomUnavailableReason !== "left" && ( <div> You can also{" "} <WithHoverSound> - <a href="/">create a new room</a>. - </WithHoverSound> + <a href="/">create a new room</a> + </WithHoverSound>. </div> )} </div> @@ -1101,6 +1102,9 @@ class UIRoot extends Component { {entryFinished && ( <form onSubmit={this.sendMessage}> <div className={styles.messageEntryInRoom} style={{ height: pendingMessageFieldHeight }}> + {this.state.pendingMessage.startsWith("/") && ( + <ChatCommandHelp matchingPrefix={this.state.pendingMessage.substring(1)} /> + )} <textarea style={{ height: pendingMessageTextareaHeight }} className={classNames([ @@ -1209,7 +1213,7 @@ class UIRoot extends Component { )} <WithHoverSound> - <button onClick={() => this.showHelpDialog()} className={styles.helpIcon}> + <button onClick={() => this.showHelpDialog()} className={classNames([styles.helpIcon, "help-button"])}> <i> <FontAwesomeIcon icon={faQuestion} /> </i>