Merge branch 'develop' of github.com:thecodingmachine/workadventure into develop

This commit is contained in:
Lurkars 2021-06-24 09:19:55 +02:00
commit a0418ddb78
171 changed files with 15422 additions and 2724 deletions

View file

@ -149,6 +149,37 @@ jobs:
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- name: Write certificate
run: echo "${CERTS_PRIVATE_KEY}" > secret.key && chmod 0600 secret.key
env:
CERTS_PRIVATE_KEY: ${{ secrets.CERTS_PRIVATE_KEY }}
- name: Download certificate
run: mkdir secrets && scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i secret.key ubuntu@cert.workadventu.re:./config/live/workadventu.re/* secrets/
- name: Create namespace
uses: steebchen/kubectl@v1.0.0
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_FILE_BASE64 }}
with:
args: create namespace workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
continue-on-error: true
- name: Delete old certificates in namespace
uses: steebchen/kubectl@v1.0.0
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_FILE_BASE64 }}
with:
args: -n workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }} delete secret certificate-tls
continue-on-error: true
- name: Install certificates in namespace
uses: steebchen/kubectl@v1.0.0
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_FILE_BASE64 }}
with:
args: -n workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }} create secret tls certificate-tls --key="secrets/privkey.pem" --cert="secrets/fullchain.pem"
- name: Deploy
uses: thecodingmachine/deeployer-action@master
env:
@ -168,4 +199,4 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
msg: Environment deployed at https://play.${{ env.GITHUB_HEAD_REF_SLUG }}.test.workadventu.re
msg: Environment deployed at https://play-${{ env.GITHUB_HEAD_REF_SLUG }}.test.workadventu.re

71
.github/workflows/codeql-analysis.yml vendored Normal file
View file

@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ develop ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ develop ]
schedule:
- cron: '24 17 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View file

@ -52,6 +52,10 @@ jobs:
PUSHER_URL: "//localhost:8080"
working-directory: "front"
- name: "Svelte check"
run: yarn run svelte-check
working-directory: "front"
- name: "Lint"
run: yarn run lint
working-directory: "front"

3
.gitignore vendored
View file

@ -6,4 +6,5 @@ docker-compose.override.yaml
*.DS_Store
maps/yarn.lock
maps/dist/computer.js
maps/dist/computer.js.map
maps/dist/computer.js.map
/node_modules/

1
.husky/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
_

15
.husky/pre-commit Executable file
View file

@ -0,0 +1,15 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
(
cd front || exit
yarn run precommit
)
(
cd pusher || exit
yarn run precommit
)
(
cd back || exit
yarn run precommit
)

View file

@ -1,4 +1,28 @@
## Version 1.3.9 - in dev
## Version 1.4.x-dev
### Updates
- Added the ability to have animated tiles in maps #1216 #1217
- Enabled outlines on actionable item again (they were disabled when migrating to Phaser 3.50) #1218
- Enabled outlines on player names (when the mouse hovers on a player you can interact with) #1219
- Migrated the admin console to Svelte, and redesigned the console #1211
- Layer properties (like `exitUrl`, `silent`, etc...) can now also used in tile properties #1210 (@jonnytest1)
- New scripting API features :
- Use `WA.room.showLayer(): void` to show a layer
- Use `WA.room.hideLayer(): void` to hide a layer
- Use `WA.room.setProperty() : void` to add or change existing property of a layer
- Use `WA.player.onPlayerMove(): void` to track the movement of the current player
- Use `WA.room.getCurrentUser(): Promise<User>` to get the ID, name and tags of the current player
- Use `WA.room.getCurrentRoom(): Promise<Room>` to get the ID, JSON map file, url of the map of the current room and the layer where the current player started
- Use `WA.ui.registerMenuCommand(): void` to add a custom menu
## Version 1.4.1
### Bugfixes
- Loading errors after the preload stage should not crash the game anymore
## Version 1.4.0
### BREAKING CHANGES
@ -34,6 +58,7 @@
- New scripting API features:
- Use `WA.loadSound(): Sound` to load / play / stop a sound
### Bug Fixes
- Pinch gesture does no longer move the character

58
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,58 @@
# Contributing to WorkAdventure
Are you looking to help on WorkAdventure? Awesome, feel welcome and read the following sections in order to know how to
ask questions and how to work on something.
## Contributions we are seeking
We love to receive contributions from our community — you!
There are many ways to contribute, from writing tutorials or blog posts, improving the documentation,
submitting bug reports and feature requests or writing code which can be incorporated into WorkAdventure itself.
## Using the issue tracker
First things first: **Do NOT report security vulnerabilities in public issues!**.
Please read the [security guide](SECURITY.md) to learn who to do a security disclosure to the WorkAdventure core team.
You can use [GitHub issue tracker](https://github.com/thecodingmachine/workadventure/issues) to:
- File bug reports
- Ask for feature requests
If you have more general questions, a good place to ask is [our Discord server](https://discord.gg/YGtngdh9gt).
Finally, you can come and talk to the WorkAdventure core team... on WorkAdventure, of course! [Our offices are here](https://play.staging.workadventu.re/@/tcm/workadventure/wa-village).
## Pull requests
Good pull requests - patches, improvements, new features - are a fantastic help. They should remain focused in scope
and avoid containing unrelated commits.
Please ask first before embarking on any significant pull request (e.g. implementing features, refactoring code),
otherwise you risk spending a lot of time working on something that the project's developers might not want to merge
into the project.
You can ask us on [Discord](https://discord.gg/YGtngdh9gt) or in the [GitHub issues](https://github.com/thecodingmachine/workadventure/issues).
### Linting your code
Before committing, be sure to install the "Prettier" precommit hook that will reformat your code to our coding style.
In order to enable the "Prettier" precommit hook, at the root of the project, run:
```console
$ yarn run install
$ yarn run prepare
```
### Providing tests
WorkAdventure is based on a video game engine (Phaser), and video games are not the easiest programs to unit test.
Nevertheless, if your code can be unit tested, please provide a unit test (we use Jasmine).
If you are providing a new feature, you should setup a test map in the `maps/tests` directory. The test map should contain
some description text describing how to test the feature. Finally, you should modify the `maps/tests/index.html` file
to add a reference to your newly created test map.

20
SECURITY.md Normal file
View file

@ -0,0 +1,20 @@
# Security Policy
## Reporting a Vulnerability
First things first: **Do NOT report security vulnerabilities in public issues!**
Please disclose responsibly by sending
a mail at security@workadventu.re (you can also ping us in the GitHub issues, but please, no details in the issues!)
We will assess the issue as soon as possible on a best-effort basis and will give you an estimate for when we have a fix
and release available for an eventual public disclosure.
We do not have a bug bounty program.
## Supported Versions
We only apply security patches on the latest tagged release and on the `master` and `develop` branches
Unless specified otherwise, do not expect us to fix security issues on past releases. We are only maintaining one release:
the latest one, which is online at https://play.workadventu.re.

1
back/.prettierignore Normal file
View file

@ -0,0 +1 @@
src/Messages/generated

4
back/.prettierrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"printWidth": 120,
"tabWidth": 4
}

View file

@ -11,7 +11,10 @@
"profile": "tsc && node --prof ./dist/server.js",
"test": "ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json",
"lint": "node_modules/.bin/eslint src/ . --ext .ts",
"fix": "node_modules/.bin/eslint --fix src/ . --ext .ts"
"fix": "node_modules/.bin/eslint --fix src/ . --ext .ts",
"precommit": "lint-staged",
"pretty": "yarn prettier --write 'src/**/*.{ts,tsx}'",
"pretty-check": "yarn prettier --check 'src/**/*.{ts,tsx}'"
},
"repository": {
"type": "git",
@ -38,23 +41,17 @@
"homepage": "https://github.com/thecodingmachine/workadventure#readme",
"dependencies": {
"axios": "^0.21.1",
"body-parser": "^1.19.0",
"busboy": "^0.3.1",
"circular-json": "^0.5.9",
"debug": "^4.3.1",
"generic-type-guard": "^3.2.0",
"google-protobuf": "^3.13.0",
"grpc": "^1.24.4",
"http-status-codes": "^1.4.0",
"iterall": "^1.3.0",
"jsonwebtoken": "^8.5.1",
"mkdirp": "^1.0.4",
"multer": "^1.4.2",
"prom-client": "^12.0.0",
"query-string": "^6.13.3",
"systeminformation": "^4.31.1",
"ts-node-dev": "^1.0.0-pre.44",
"typescript": "^3.8.3",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",
"uuidv4": "^6.0.7"
},
@ -71,6 +68,15 @@
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"eslint": "^6.8.0",
"jasmine": "^3.5.0"
"jasmine": "^3.5.0",
"lint-staged": "^11.0.0",
"prettier": "^2.3.1",
"ts-node-dev": "^1.0.0-pre.44",
"typescript": "^3.8.3"
},
"lint-staged": {
"*.ts": [
"prettier --write"
]
}
}

View file

@ -1,9 +1,6 @@
import {
BatchMessage,
PusherToBackMessage,
ServerToAdminClientMessage,
ServerToClientMessage,
SubMessage, UserJoinedRoomMessage, UserLeftRoomMessage
UserJoinedRoomMessage, UserLeftRoomMessage
} from "../Messages/generated/messages_pb";
import {AdminSocket} from "../RoomManager";

View file

@ -108,6 +108,7 @@ export class GameRoom {
this.positionNotifier,
socket,
joinRoomMessage.getTagList(),
joinRoomMessage.getVisitcardurl(),
joinRoomMessage.getName(),
ProtobufUtils.toCharacterLayerObjects(joinRoomMessage.getCharacterlayerList()),
joinRoomMessage.getCompanion()

View file

@ -22,6 +22,7 @@ export class User implements Movable {
private positionNotifier: PositionNotifier,
public readonly socket: UserSocket,
public readonly tags: string[],
public readonly visitCardUrl: string|null,
public readonly name: string,
public readonly characterLayers: CharacterLayer[],
public readonly companion?: CompanionMessage

View file

@ -11,7 +11,7 @@ import {
JoinRoomMessage,
PlayGlobalMessage,
PusherToBackMessage,
QueryJitsiJwtMessage, RefreshRoomPromptMessage, RequestVisitCardMessage,
QueryJitsiJwtMessage, RefreshRoomPromptMessage,
ServerToAdminClientMessage,
ServerToClientMessage,
SilentMessage,
@ -74,8 +74,6 @@ const roomManager: IRoomManagerServer = {
socketManager.handleQueryJitsiJwtMessage(user, message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage);
} else if (message.hasEmotepromptmessage()){
socketManager.handleEmoteEventMessage(room, user, message.getEmotepromptmessage() as EmotePromptMessage);
} else if (message.hasRequestvisitcardmessage()) {
socketManager.handleRequestVisitCardMessage(room, user, message.getRequestvisitcardmessage() as RequestVisitCardMessage);
}else if (message.hasSendusermessage()) {
const sendUserMessage = message.getSendusermessage();
if(sendUserMessage !== undefined) {

View file

@ -1,22 +0,0 @@
import {ADMIN_API_TOKEN, ADMIN_API_URL} from "../Enum/EnvironmentVariable";
import Axios from "axios";
class AdminApi {
fetchVisitCardUrl(membershipUuid: string): Promise<string> {
if (ADMIN_API_URL) {
return Axios.get(ADMIN_API_URL + '/api/membership/'+membershipUuid,
{headers: {"Authorization": `${ADMIN_API_TOKEN}`}}
).then((res) => {
return res.data;
}).catch(() => {
return 'INVALID';
});
} else {
return Promise.resolve('INVALID')
}
}
}
export const adminApi = new AdminApi();

View file

@ -27,7 +27,7 @@ import {
WorldFullWarningMessage,
UserLeftZoneMessage,
EmoteEventMessage,
BanUserMessage, RefreshRoomMessage, EmotePromptMessage, RequestVisitCardMessage, VisitCardMessage,
BanUserMessage, RefreshRoomMessage, EmotePromptMessage,
} from "../Messages/generated/messages_pb";
import {User, UserSocket} from "../Model/User";
import {ProtobufUtils} from "../Model/Websocket/ProtobufUtils";
@ -51,7 +51,6 @@ import {Zone} from "_Model/Zone";
import Debug from "debug";
import {Admin} from "_Model/Admin";
import crypto from "crypto";
import {adminApi} from "./AdminApi";
const debug = Debug('sockermanager');
@ -300,6 +299,9 @@ export class SocketManager {
userJoinedZoneMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(thing.characterLayers));
userJoinedZoneMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition()));
userJoinedZoneMessage.setFromzone(this.toProtoZone(fromZone));
if (thing.visitCardUrl) {
userJoinedZoneMessage.setVisitcardurl(thing.visitCardUrl);
}
userJoinedZoneMessage.setCompanion(thing.companion);
const subMessage = new SubToPusherMessage();
@ -605,6 +607,9 @@ export class SocketManager {
userJoinedMessage.setName(thing.name);
userJoinedMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(thing.characterLayers));
userJoinedMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition()));
if (thing.visitCardUrl) {
userJoinedMessage.setVisitcardurl(thing.visitCardUrl);
}
userJoinedMessage.setCompanion(thing.companion);
const subMessage = new SubToPusherMessage();
@ -770,21 +775,6 @@ export class SocketManager {
emoteEventMessage.setActoruserid(user.id);
room.emitEmoteEvent(user, emoteEventMessage);
}
async handleRequestVisitCardMessage(room: GameRoom, user: User, requestvisitcardmessage: RequestVisitCardMessage): Promise<void> {
const targetUser = room.getUserById(requestvisitcardmessage.getTargetuserid());
if (!targetUser) {
throw 'Could not find user for id '+requestvisitcardmessage.getTargetuserid();
}
const url = await adminApi.fetchVisitCardUrl(targetUser.uuid);
const visitCardMessage = new VisitCardMessage();
visitCardMessage.setUrl(url);
const clientMessage = new ServerToClientMessage();
clientMessage.setVisitcardmessage(visitCardMessage);
user.socket.write(clientMessage);
}
}
export const socketManager = new SocketManager();

View file

@ -1,10 +1,6 @@
import "jasmine";
import {GameRoom, ConnectCallback, DisconnectCallback } from "_Model/GameRoom";
import {Point} from "../src/Model/Websocket/MessageUserPosition";
import { Group } from "../src/Model/Group";
import {PositionNotifier} from "../src/Model/PositionNotifier";
import {User, UserSocket} from "../src/Model/User";
import {PointInterface} from "../src/Model/Websocket/PointInterface";
import {Zone} from "_Model/Zone";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
@ -30,14 +26,14 @@ describe("PositionNotifier", () => {
y: 500,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as UserSocket, [], 'foo', []);
}, false, positionNotifier, {} as UserSocket, [], null, 'foo', []);
const user2 = new User(2, 'test', '10.0.0.2', {
x: -9999,
y: -9999,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as UserSocket, [], 'foo', []);
}, false, positionNotifier, {} as UserSocket, [], null, 'foo', []);
positionNotifier.addZoneListener({} as ZoneSocket, 0, 0);
positionNotifier.addZoneListener({} as ZoneSocket, 0, 1);
@ -105,14 +101,14 @@ describe("PositionNotifier", () => {
y: 500,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as UserSocket, [], 'foo', []);
}, false, positionNotifier, {} as UserSocket, [], null, 'foo', []);
const user2 = new User(2, 'test', '10.0.0.2', {
x: 0,
y: 0,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as UserSocket, [], 'foo', []);
}, false, positionNotifier, {} as UserSocket, [], null, 'foo', []);
const listener = {} as ZoneSocket;
positionNotifier.addZoneListener(listener, 0, 0);

File diff suppressed because it is too large Load diff

View file

@ -209,9 +209,9 @@
}
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
"integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"requires": {
"is-glob": "^4.0.1"
}
@ -688,9 +688,9 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"ws": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA=="
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="
},
"xtend": {
"version": "4.0.2",

View file

@ -24,7 +24,7 @@
"@types/ws": "^7.2.6",
"ts-node-dev": "^1.0.0-pre.62",
"typescript": "^4.0.2",
"ws": "^7.3.1"
"ws": "^7.4.6"
},
"devDependencies": {}
}

View file

@ -148,8 +148,8 @@ get-stdin@^4.0.1:
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
glob-parent@~5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
dependencies:
is-glob "^4.0.1"
@ -515,9 +515,9 @@ wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
ws@^7.3.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8"
ws@^7.4.6:
version "7.4.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
xtend@^4.0.0:
version "4.0.2"

View file

@ -11,8 +11,7 @@
"back1": {
"image": "thecodingmachine/workadventure-back:"+tag,
"host": {
"url": "api1."+url,
"https": "enable",
"url": "api1-"+url,
"containerPort": 8080
},
"ports": [8080, 50051],
@ -30,8 +29,7 @@
"back2": {
"image": "thecodingmachine/workadventure-back:"+tag,
"host": {
"url": "api2."+url,
"https": "enable",
"url": "api2-"+url,
"containerPort": 8080
},
"ports": [8080, 50051],
@ -50,8 +48,7 @@
"replicas": 2,
"image": "thecodingmachine/workadventure-pusher:"+tag,
"host": {
"url": "pusher."+url,
"https": "enable"
"url": "pusher-"+url,
},
"ports": [8080],
"env": {
@ -68,27 +65,25 @@
"front": {
"image": "thecodingmachine/workadventure-front:"+tag,
"host": {
"url": "play."+url,
"https": "enable"
"url": "play-"+url,
},
"ports": [80],
"env": {
"PUSHER_URL": "//pusher."+url,
"UPLOADER_URL": "//uploader."+url,
"PUSHER_URL": "//pusher-"+url,
"UPLOADER_URL": "//uploader-"+url,
"ADMIN_URL": "//"+url,
"JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
"TURN_SERVER": "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443",
"JITSI_PRIVATE_MODE": if env.SECRET_JITSI_KEY != '' then "true" else "false",
"START_ROOM_URL": "/_/global/maps."+url+"/Floor0/floor0.json"
"START_ROOM_URL": "/_/global/maps-"+url+"/Floor0/floor0.json"
//"GA_TRACKING_ID": "UA-10196481-11"
}
},
"uploader": {
"image": "thecodingmachine/workadventure-uploader:"+tag,
"host": {
"url": "uploader."+url,
"https": "enable",
"url": "uploader-"+url,
"containerPort": 8080
},
"ports": [8080],
@ -98,16 +93,12 @@
"maps": {
"image": "thecodingmachine/workadventure-maps:"+tag,
"host": {
"url": "maps."+url,
"https": "enable"
"url": "maps-"+url
},
"ports": [80]
},
},
"config": {
"https": {
"mail": "d.negrier@thecodingmachine.com"
},
k8sextension(k8sConf)::
k8sConf + {
back1+: {
@ -122,6 +113,14 @@
}
}
}
},
ingress+: {
spec+: {
tls+: [{
hosts: ["api1-"+url],
secretName: "certificate-tls"
}]
}
}
},
back2+: {
@ -136,6 +135,14 @@
}
}
}
},
ingress+: {
spec+: {
tls+: [{
hosts: ["api2-"+url],
secretName: "certificate-tls"
}]
}
}
},
pusher+: {
@ -150,8 +157,46 @@
}
}
}
}
}
},
ingress+: {
spec+: {
tls+: [{
hosts: ["pusher-"+url],
secretName: "certificate-tls"
}]
}
}
},
front+: {
ingress+: {
spec+: {
tls+: [{
hosts: ["play-"+url],
secretName: "certificate-tls"
}]
}
}
},
uploader+: {
ingress+: {
spec+: {
tls+: [{
hosts: ["uploader-"+url],
secretName: "certificate-tls"
}]
}
}
},
maps+: {
ingress+: {
spec+: {
tls+: [{
hosts: ["maps-"+url],
secretName: "certificate-tls"
}]
}
}
},
}
}
}

33
docs/maps/animations.md Normal file
View file

@ -0,0 +1,33 @@
{.section-title.accent.text-primary}
# Animating WorkAdventure maps
A tile can run an animation in loops, for example to render water or blinking lights. Each animation frame is a single
32x32 tile. To create an animation, edit the tileset in Tiled and click on the tile to animate (or pick a free tile to
not overwrite existing ones) and click on the animation editor:
<div class="px-5 card rounded d-inline-block">
<img class="document-img" src="https://workadventu.re/img/docs/anims/camera.png" alt="" />
</div>
You can now add all tiles that should be part of the animation via drag and drop to the "playlist" and adjust the frame duration:
<div>
<figure class="figure">
<img class="figure-img img-fluid rounded" src="https://workadventu.re/img/docs/anims/animation_editor.png" alt="" />
<figcaption class="figure-caption">The tile animation editor</figcaption>
</figure>
</div>
You can preview animations directly in Tiled, using the "Show tile animations" option:
<div>
<figure class="figure">
<img class="figure-img img-fluid rounded" src="https://workadventu.re/img/docs/anims/settings_show_animations.png" alt="" />
<figcaption class="figure-caption">The Show Tile Animations option</figcaption>
</figure>
</div>
{.alert.alert-info}
**Tip:** The engine does tile-updates every 100ms, animations with a shorter frame duration will most likely not look that good or may even do not work.

37
docs/maps/api-chat.md Normal file
View file

@ -0,0 +1,37 @@
{.section-title.accent.text-primary}
# API Chat functions reference
### Sending a message in the chat
```
WA.chat.sendChatMessage(message: string, author: string): void
```
Sends a message in the chat. The message is only visible in the browser of the current user.
* **message**: the message to be displayed in the chat
* **author**: the name displayed for the author of the message. It does not have to be a real user.
Example:
```javascript
WA.chat.sendChatMessage('Hello world', 'Mr Robot');
```
### Listening to messages from the chat
```javascript
WA.chat.onChatMessage(callback: (message: string) => void): void
```
Listens to messages typed by the current user and calls the callback. Messages from other users in the chat cannot be listened to.
* **callback**: the function that will be called when a message is received. It contains the message typed by the user.
Example:
```javascript
WA.chat.onChatMessage((message => {
console.log('The user typed a message', message);
}));
```

29
docs/maps/api-controls.md Normal file
View file

@ -0,0 +1,29 @@
{.section-title.accent.text-primary}
# API Controls functions Reference
### Disabling / restoring controls
```
WA.controls.disablePlayerControls(): void
WA.controls.restorePlayerControls(): void
```
These 2 methods can be used to completely disable player controls and to enable them again.
When controls are disabled, the user cannot move anymore using keyboard input. This can be useful in a "First Time User Experience" part, to display an important message to a user before letting him/her move again.
Example:
```javascript
WA.room.onEnterZone('myZone', () => {
WA.controls.disablePlayerControls();
WA.ui.openPopup("popupRectangle", 'This is an imporant message!', [{
label: "Got it!",
className: "primary",
callback: (popup) => {
WA.controls.restorePlayerControls();
popup.close();
}
}]);
});
```

View file

@ -0,0 +1,20 @@
{.section-title.accent.text-primary}
# API Reference - Deprecated functions
The list of functions below is **deprecated**. You should not use those but. use the replacement functions.
- Method `WA.sendChatMessage` is deprecated. It has been renamed to `WA.chat.sendChatMessage`.
- Method `WA.disablePlayerControls` is deprecated. It has been renamed to `WA.controls.disablePlayerControls`.
- Method `WA.restorePlayerControls` is deprecated. It has been renamed to `WA.controls.restorePlayerControls`.
- Method `WA.displayBubble` is deprecated. It has been renamed to `WA.ui.displayBubble`.
- Method `WA.removeBubble` is deprecated. It has been renamed to `WA.ui.removeBubble`.
- Method `WA.openTab` is deprecated. It has been renamed to `WA.nav.openTab`.
- Method `WA.loadSound` is deprecated. It has been renamed to `WA.sound.loadSound`.
- Method `WA.goToPage` is deprecated. It has been renamed to `WA.nav.goToPage`.
- Method `WA.goToRoom` is deprecated. It has been renamed to `WA.nav.goToRoom`.
- Method `WA.openCoWebSite` is deprecated. It has been renamed to `WA.nav.openCoWebSite`.
- Method `WA.closeCoWebSite` is deprecated. It has been renamed to `WA.nav.closeCoWebSite`.
- Method `WA.openPopup` is deprecated. It has been renamed to `WA.ui.openPopup`.
- Method `WA.onChatMessage` is deprecated. It has been renamed to `WA.chat.onChatMessage`.
- Method `WA.onEnterZone` is deprecated. It has been renamed to `WA.room.onEnterZone`.
- Method `WA.onLeaveZone` is deprecated. It has been renamed to `WA.room.onLeaveZone`.

68
docs/maps/api-nav.md Normal file
View file

@ -0,0 +1,68 @@
{.section-title.accent.text-primary}
# API Navigation functions reference
### Opening a web page in a new tab
```
WA.nav.openTab(url: string): void
```
Opens the webpage at "url" in your browser, in a new tab.
Example:
```javascript
WA.nav.openTab('https://www.wikipedia.org/');
```
### Opening a web page in the current tab
```
WA.nav.goToPage(url: string): void
```
Opens the webpage at "url" in your browser in place of WorkAdventure. WorkAdventure will be completely unloaded.
Example:
```javascript
WA.nav.goToPage('https://www.wikipedia.org/');
```
### Going to a different map from the script
```
WA.nav.goToRoom(url: string): void
```
Load the map at url without unloading workadventure
relative urls: "../subFolder/map.json[#start-layer-name]"
global urls: "/_/global/domain/path/map.json[#start-layer-name]"
Example:
```javascript
WA.nav.goToRoom("/@/tcm/workadventure/floor0") // workadventure urls
WA.nav.goToRoom('../otherMap/map.json');
WA.nav.goToRoom("/_/global/<path to global map>.json#start-layer-2")
```
### Opening/closing a web page in an iFrame
```
WA.nav.openCoWebSite(url: string): void
WA.nav.closeCoWebSite(): void
```
Opens the webpage at "url" in an iFrame (on the right side of the screen) or close that iFrame.
Example:
```javascript
WA.nav.openCoWebSite('https://www.wikipedia.org/');
// ...
WA.nav.closeCoWebSite();
```

21
docs/maps/api-player.md Normal file
View file

@ -0,0 +1,21 @@
{.section-title.accent.text-primary}
# API Player functions Reference
### Listen to player movement
```
WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void;
```
Listens to the movement of the current user and calls the callback. Sends an event when the user stops moving, changes direction and every 200ms when moving in the same direction.
The event has the following attributes :
* **moving (boolean):** **true** when the current player is moving, **false** otherwise.
* **direction (string):** **"right"** | **"left"** | **"down"** | **"top"** the direction where the current player is moving.
* **x (number):** coordinate X of the current player.
* **y (number):** coordinate Y of the current player.
**callback:** the function that will be called when the current player is moving. It contains the event.
Example :
```javascript
WA.player.onPlayerMove(console.log);
```

View file

@ -1,237 +1,12 @@
{.section-title.accent.text-primary}
# API Reference
### Sending a message in the chat
- [Navigation functions](api-nav.md)
- [Chat functions](api-chat.md)
- [Room functions](api-room.md)
- [Player functions](api-player.md)
- [UI functions](api-ui.md)
- [Sound functions](api-sound.md)
- [Controls functions](api-controls.md)
```
sendChatMessage(message: string, author: string): void
```
Sends a message in the chat. The message is only visible in the browser of the current user.
* **message**: the message to be displayed in the chat
* **author**: the name displayed for the author of the message. It does not have to be a real user.
Example:
```javascript
WA.sendChatMessage('Hello world', 'Mr Robot');
```
### Listening to messages from the chat
```javascript
onChatMessage(callback: (message: string) => void): void
```
Listens to messages typed by the current user and calls the callback. Messages from other users in the chat cannot be listened to.
* **callback**: the function that will be called when a message is received. It contains the message typed by the user.
Example:
```javascript
WA.onChatMessage((message => {
console.log('The user typed a message', message);
}));
```
### Detecting when the user enters/leaves a zone
```
onEnterZone(name: string, callback: () => void): void
onLeaveZone(name: string, callback: () => void): void
```
Listens to the position of the current user. The event is triggered when the user enters or leaves a given zone. The name of the zone is stored in the map, on a dedicated layer with the `zone` property.
<div>
<figure class="figure">
<img src="https://workadventu.re/img/docs/trigger_event.png" class="figure-img img-fluid rounded" alt="" />
<figcaption class="figure-caption">The `zone` property, applied on a layer</figcaption>
</figure>
</div>
* **name**: the name of the zone, as defined in the `zone` property.
* **callback**: the function that will be called when a user enters or leaves the zone.
Example:
```javascript
WA.onEnterZone('myZone', () => {
WA.sendChatMessage("Hello!", 'Mr Robot');
})
WA.onLeaveZone('myZone', () => {
WA.sendChatMessage("Goodbye!", 'Mr Robot');
})
```
### Opening a popup
In order to open a popup window, you must first define the position of the popup on your map.
You can position this popup by using a "rectangle" object in Tiled that you will place on an "object" layer.
<div class="row">
<div class="col">
<img src="https://workadventu.re/img/docs/screen_popup_tiled.png" class="figure-img img-fluid rounded" alt="" />
</div>
<div class="col">
<img src="https://workadventu.re/img/docs/screen_popup_in_game.png" class="figure-img img-fluid rounded" alt="" />
</div>
</div>
```
openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup
```
* **targetObject**: the name of the rectangle object defined in Tiled.
* **message**: the message to display in the popup.
* **buttons**: an array of action buttons defined underneath the popup.
Action buttons are `ButtonDescriptor` objects containing these properties.
* **label (_string_)**: The label of the button.
* **className (_string_)**: The visual type of the button. Can be one of "normal", "primary", "success", "warning", "error", "disabled".
* **callback (_(popup: Popup)=>void_)**: Callback called when the button is pressed.
Please note that `openPopup` returns an object of the `Popup` class. Also, the callback called when a button is clicked is passed a `Popup` object.
The `Popup` class that represents an open popup contains a single method: `close()`. This will obviously close the popup when called.
```javascript
class Popup {
/**
* Closes the popup
*/
close() {};
}
```
Example:
```javascript
let helloWorldPopup;
// Open the popup when we enter a given zone
helloWorldPopup = WA.onEnterZone('myZone', () => {
WA.openPopup("popupRectangle", 'Hello world!', [{
label: "Close",
className: "primary",
callback: (popup) => {
// Close the popup when the "Close" button is pressed.
popup.close();
}
});
}]);
// Close the popup when we leave the zone.
WA.onLeaveZone('myZone', () => {
helloWorldPopup.close();
});
```
### Disabling / restoring controls
```
disablePlayerControls(): void
restorePlayerControls(): void
```
These 2 methods can be used to completely disable player controls and to enable them again.
When controls are disabled, the user cannot move anymore using keyboard input. This can be useful in a "First Time User Experience" part, to display an important message to a user before letting him/her move again.
Example:
```javascript
WA.onEnterZone('myZone', () => {
WA.disablePlayerControls();
WA.openPopup("popupRectangle", 'This is an imporant message!', [{
label: "Got it!",
className: "primary",
callback: (popup) => {
WA.restorePlayerControls();
popup.close();
}
}]);
});
```
### Opening a web page in a new tab
```
openTab(url: string): void
```
Opens the webpage at "url" in your browser, in a new tab.
Example:
```javascript
WA.openTab('https://www.wikipedia.org/');
```
### Opening a web page in the current tab
```
goToPage(url: string): void
```
Opens the webpage at "url" in your browser in place of WorkAdventure. WorkAdventure will be completely unloaded.
Example:
```javascript
WA.goToPage('https://www.wikipedia.org/');
```
### Opening/closing a web page in an iFrame
```
openCoWebSite(url: string): void
closeCoWebSite(): void
```
Opens the webpage at "url" in an iFrame (on the right side of the screen) or close that iFrame.
Example:
```javascript
WA.openCoWebSite('https://www.wikipedia.org/');
// ...
WA.closeCoWebSite();
```
### Load a sound from an url
```
loadSound(url: string): Sound
```
Load a sound from an url
Please note that `loadSound` returns an object of the `Sound` class
The `Sound` class that represents a loaded sound contains two methods: `play(soundConfig : SoundConfig|undefined)` and `stop()`
The parameter soundConfig is optional, if you call play without a Sound config the sound will be played with the basic configuration.
Example:
```javascript
var mySound = WA.loadSound("Sound.ogg");
var config = {
volume : 0.5,
loop : false,
rate : 1,
detune : 1,
delay : 0,
seek : 0,
mute : false
}
mySound.play(config);
// ...
mySound.stop();
```
- [List of deprecated functions](api-deprecated.md)

114
docs/maps/api-room.md Normal file
View file

@ -0,0 +1,114 @@
{.section-title.accent.text-primary}
# API Room functions Reference
### Working with group layers
If you use group layers in your map, to reference a layer in a group you will need to use a `/` to join layer names together.
Example :
<div class="row">
<div class="col">
<img src="https://workadventu.re/img/docs/groupLayer.png" class="figure-img img-fluid rounded" alt="" />
</div>
</div>
The name of the layers of this map are :
* `entries/start`
* `bottom/ground/under`
* `bottom/build/carpet`
* `wall`
### Detecting when the user enters/leaves a zone
```
WA.room.onEnterZone(name: string, callback: () => void): void
WA.room.onLeaveZone(name: string, callback: () => void): void
```
Listens to the position of the current user. The event is triggered when the user enters or leaves a given zone. The name of the zone is stored in the map, on a dedicated layer with the `zone` property.
<div>
<figure class="figure">
<img src="https://workadventu.re/img/docs/trigger_event.png" class="figure-img img-fluid rounded" alt="" />
<figcaption class="figure-caption">The `zone` property, applied on a layer</figcaption>
</figure>
</div>
* **name**: the name of the zone, as defined in the `zone` property.
* **callback**: the function that will be called when a user enters or leaves the zone.
Example:
```javascript
WA.room.onEnterZone('myZone', () => {
WA.chat.sendChatMessage("Hello!", 'Mr Robot');
})
WA.room.onLeaveZone('myZone', () => {
WA.chat.sendChatMessage("Goodbye!", 'Mr Robot');
})
```
### Show / Hide a layer
```
WA.room.showLayer(layerName : string): void
WA.room.hideLayer(layerName : string) : void
```
These 2 methods can be used to show and hide a layer.
Example :
```javascript
WA.room.showLayer('bottom');
//...
WA.room.hideLayer('bottom');
```
### Set/Create properties in a layer
```
WA.room.setProperty(layerName : string, propertyName : string, propertyValue : string | number | boolean | undefined) : void;
```
Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist, create the property `propertyName` and set the value of the property at `propertyValue`.
Example :
```javascript
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
```
### Getting information on the current room
```
WA.room.getCurrentRoom(): Promise<Room>
```
Return a promise that resolves to a `Room` object with the following attributes :
* **id (string) :** ID of the current room
* **map (ITiledMap) :** contains the JSON map file with the properties that were setted by the script if `setProperty` was called.
* **mapUrl (string) :** Url of the JSON map file
* **startLayer (string | null) :** Name of the layer where the current user started, only if different from `start` layer
Example :
```javascript
WA.room.getCurrentRoom((room) => {
if (room.id === '42') {
console.log(room.map);
window.open(room.mapUrl, '_blank');
}
})
```
### Getting information on the current user
```
WA.player.getCurrentUser(): Promise<User>
```
Return a promise that resolves to a `User` object with the following attributes :
* **id (string) :** ID of the current user
* **nickName (string) :** name displayed above the current user
* **tags (string[]) :** list of all the tags of the current user
Example :
```javascript
WA.room.getCurrentUser().then((user) => {
if (user.nickName === 'ABC') {
console.log(user.tags);
}
})
```

34
docs/maps/api-sound.md Normal file
View file

@ -0,0 +1,34 @@
{.section-title.accent.text-primary}
# API Sound functions Reference
### Load a sound from an url
```
WA.sound.loadSound(url: string): Sound
```
Load a sound from an url
Please note that `loadSound` returns an object of the `Sound` class
The `Sound` class that represents a loaded sound contains two methods: `play(soundConfig : SoundConfig|undefined)` and `stop()`
The parameter soundConfig is optional, if you call play without a Sound config the sound will be played with the basic configuration.
Example:
```javascript
var mySound = WA.sound.loadSound("Sound.ogg");
var config = {
volume : 0.5,
loop : false,
rate : 1,
detune : 1,
delay : 0,
seek : 0,
mute : false
}
mySound.play(config);
// ...
mySound.stop();
```

89
docs/maps/api-ui.md Normal file
View file

@ -0,0 +1,89 @@
{.section-title.accent.text-primary}
# API UI functions Reference
### Opening a popup
In order to open a popup window, you must first define the position of the popup on your map.
You can position this popup by using a "rectangle" object in Tiled that you will place on an "object" layer.
<div class="row">
<div class="col">
<img src="https://workadventu.re/img/docs/screen_popup_tiled.png" class="figure-img img-fluid rounded" alt="" />
</div>
<div class="col">
<img src="https://workadventu.re/img/docs/screen_popup_in_game.png" class="figure-img img-fluid rounded" alt="" />
</div>
</div>
```
WA.ui.openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup
```
* **targetObject**: the name of the rectangle object defined in Tiled.
* **message**: the message to display in the popup.
* **buttons**: an array of action buttons defined underneath the popup.
Action buttons are `ButtonDescriptor` objects containing these properties.
* **label (_string_)**: The label of the button.
* **className (_string_)**: The visual type of the button. Can be one of "normal", "primary", "success", "warning", "error", "disabled".
* **callback (_(popup: Popup)=>void_)**: Callback called when the button is pressed.
Please note that `openPopup` returns an object of the `Popup` class. Also, the callback called when a button is clicked is passed a `Popup` object.
The `Popup` class that represents an open popup contains a single method: `close()`. This will obviously close the popup when called.
```javascript
class Popup {
/**
* Closes the popup
*/
close() {};
}
```
Example:
```javascript
let helloWorldPopup;
// Open the popup when we enter a given zone
helloWorldPopup = WA.room.onEnterZone('myZone', () => {
WA.ui.openPopup("popupRectangle", 'Hello world!', [{
label: "Close",
className: "primary",
callback: (popup) => {
// Close the popup when the "Close" button is pressed.
popup.close();
}
});
}]);
// Close the popup when we leave the zone.
WA.room.onLeaveZone('myZone', () => {
helloWorldPopup.close();
});
```
### Add custom menu
```typescript
WA.ui.registerMenuCommand(menuCommand: string, callback: (menuCommand: string) => void): void
```
Add a custom menu item containing the text `commandDescriptor` in the main menu. A click on the menu will trigger the `callback`.
Custom menu exist only until the map is unloaded, or you leave the iframe zone of the script.
Example:
```javascript
WA.ui.registerMenuCommand("test", () => {
WA.chat.sendChatMessage("test clicked", "menu cmd")
})
```
<div class="col">
<img src="https://workadventu.re/img/docs/menu-command.png" class="figure-img img-fluid rounded" alt="" />
</div>

View file

@ -101,7 +101,7 @@ You can now start by testing this with a simple message sent to the chat.
```html
...
<script>
WA.sendChatMessage('Hello world', 'Mr Robot');
WA.chat.sendChatMessage('Hello world', 'Mr Robot');
</script>
...
```

92
docs/maps/wa-maps.md Normal file
View file

@ -0,0 +1,92 @@
{.section-title.accent.text-primary}
# About WorkAdventure maps
A WorkAdventure map is a map in "JSON" format generated by [Tiled](https://www.mapeditor.org/).
## Tiles
A map is made of "tiles" (we can also call them "sprites"). In WorkAdventure, the tiles are small images of 32x32 pixels.
Tiles may have transparent parts. Many tiles can be stored in a single PNG file. We call this file a "tileset".
There are many tilesets available on the internet. Some examples of websites offering awesome tiles:
* [itch.io](https://itch.io/)
* [opengameart.org](https://opengameart.org/)
* [deviantart.com](https://www.deviantart.com/)
Keep in mind the size of tiles and do not forget to check the license of the tileset you are using!
## How to design "pixel" tiles
You can design your own tiles as well as change existing tiles, this is usually referred to as "pixeling". You can start drawing your own tiles with [Piskel](https://www.piskelapp.com/). It is easy to use and well targeted at "pixeling". If you are getting serious about pixeling, the awesome folks at the Chaos Computer Club recommend the use of the editor [Krita](https://krita.org/). There are plenty of other editors as well.
If you are using Krita:
* Please double check that your tiles are 32x32 pixels in size. You can enable a grid under view -> show grid and under settings -> dockers -> grid you can select the grid size.
* Use transparency if you have to model transitions between different materials. This is more flexible and saves you time by not modeling every transition.
* You can follow the Pixel-Art Workshop by blinry: [media.ccc.de/v/34C3-jugend-hackt-1016-pixel_art_workshop](https://media.ccc.de/v/34C3-jugend-hackt-1016-pixel_art_workshop)
## WorkAdventure Map Rules
In order to design a map that will be readable by WorkAdventure, you will have to respect some constraints.
In particular, you will need to:
* set a start position for the players
* configure the "floor layer" (so that WorkAdventure can correctly display characters above the floor, but under the ceiling)
* eventually, you can place exits that link to other maps
A few things to notice:
* your map can have as many layers as you want
* your map MUST contain a layer named "floorLayer" of type "objectgroup" that represents the layer on which characters will be drawn. Every layer above the "floorLayer" will be displayed on top of the characters.
* the tilesets in your map MUST be embedded. You cannot refer to an external typeset in a TSX file. Click the "embed tileset" button in the tileset tab to embed tileset data.
* your map MUST be exported in JSON format. You need to use a recent version of Tiled to get JSON format export (1.3+)
* WorkAdventure doesn't support object layers and will ignore them
* If you are starting from a blank map, your map MUST be orthogonal and tiles size should be 32x32.
<div>
<figure class="figure">
<img src="https://workadventu.re/img/docs/tiled_screenshot_1.png" class="figure-img img-fluid rounded" alt="" style="width: 70%" />
<figcaption class="figure-caption">"floorLayer" is compulsory</figcaption>
</figure>
</div>
## Building walls and "collidable" areas
By default, the characters can traverse any tiles. If you want to prevent your characeter from going through a tile (like a wall or a desktop), you must make this tile "collidable". You can do this by settings the `collides` property on a given tile.
To make a tile "collidable", you should:
1. select the relevant tileset and switch to "edit" mode:
![](https://workadventu.re/img/docs/collides-1.png){.document-img}
2. right click on a tile of the tileset to select it:
![](https://workadventu.re/img/docs/collides-2.png){.document-img}
3. on the left pane in the custom properties section, right click and select "Add properties":
![](https://workadventu.re/img/docs/collides-3.png){.document-img}
Please add a `collides` property. The type of the property must be **bool**.
4. finally, check the checkbox for the `collides` property:
![](https://workadventu.re/img/docs/collides-4.png){.document-img}
Repeat for every tile that should be "collidable".
## Adding behaviour with properties
In the next sections, you will see how you can add behaviour on your map by adding "properties".
You can add properties for a variety of features: putting exits, opening websites, meeting rooms, silent zones, etc...
You can add properties either on individual tiles of a tileset OR on a complete layer.
If you put a property on a layer, it will be triggered if your Woka walks on any tile of the layer.
The exception is the "collides" property that can only be set on tiles, but not on a complete layer.

6
front/.gitignore vendored
View file

@ -1,5 +1,9 @@
/node_modules/
/dist/bundle.js
/dist/*.js
/dist/*.js.map
/dist/*.js.LICENSE.txt
/dist/main.*.css
/dist/main.*.css.map
/dist/tests/
/yarn-error.log
/dist/webpack.config.js

1
front/.prettierignore Normal file
View file

@ -0,0 +1 @@
src/Messages/generated

4
front/.prettierrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"printWidth": 120,
"tabWidth": 4
}

View file

@ -64,7 +64,7 @@
<button id="adminConsoleButton">Admin console</button>
</section>
<section id="socialLinks" hidden>
<a class="not-button" href="https://www.facebook.com/workadventurebytcm" target="_blank"><img class="not-button" src="/resources/objects/facebook-icon.png"/></a>
<a class="not-button" href="https://www.facebook.com/workadventure.WA" target="_blank"><img class="not-button" src="/resources/objects/facebook-icon.png"/></a>
<a class="not-button" href="https://twitter.com/Workadventure_" target="_blank"><img class="not-button" src="/resources/objects/twitter-icon.png"/></a>
</section>
</main>

8393
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -18,11 +18,15 @@
"fork-ts-checker-webpack-plugin": "^6.2.9",
"html-webpack-plugin": "^5.3.1",
"jasmine": "^3.5.0",
"lint-staged": "^11.0.0",
"mini-css-extract-plugin": "^1.6.0",
"node-polyfill-webpack-plugin": "^1.1.2",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.1",
"sass": "^1.32.12",
"sass-loader": "^11.1.0",
"svelte": "^3.38.2",
"svelte-check": "^2.1.0",
"svelte-loader": "^3.1.1",
"svelte-preprocess": "^4.7.3",
"ts-loader": "^9.1.2",
@ -38,9 +42,11 @@
"@types/simple-peer": "^9.6.0",
"@types/socket.io-client": "^1.4.32",
"axios": "^0.21.1",
"cross-env": "^7.0.3",
"generic-type-guard": "^3.2.0",
"google-protobuf": "^3.13.0",
"phaser": "^3.54.0",
"phaser-animated-tiles": "workadventure/phaser-animated-tiles#da68bbededd605925621dd4f03bd27e69284b254",
"phaser3-rex-plugins": "^1.1.42",
"queue-typescript": "^1.0.1",
"quill": "1.3.6",
@ -50,10 +56,22 @@
"standardized-audio-context": "^25.2.4"
},
"scripts": {
"start": "TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" webpack serve --open",
"build": "TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" NODE_ENV=production webpack",
"start": "run-p templater serve svelte-check-watch",
"templater": "cross-env ./templater.sh",
"serve": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" webpack serve --open",
"build": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" NODE_ENV=production webpack",
"test": "TS_NODE_PROJECT=\"tsconfig-for-jasmine.json\" ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json",
"lint": "node_modules/.bin/eslint src/ . --ext .ts",
"fix": "node_modules/.bin/eslint --fix src/ . --ext .ts"
"fix": "node_modules/.bin/eslint --fix src/ . --ext .ts",
"precommit": "lint-staged",
"svelte-check-watch": "svelte-check --fail-on-warnings --fail-on-hints --compiler-warnings \"a11y-no-onchange:ignore,a11y-autofocus:ignore\" --watch",
"svelte-check": "svelte-check --fail-on-warnings --fail-on-hints --compiler-warnings \"a11y-no-onchange:ignore,a11y-autofocus:ignore\"",
"pretty": "yarn prettier --write 'src/**/*.{ts,tsx}'",
"pretty-check": "yarn prettier --check 'src/**/*.{ts,tsx}'"
},
"lint-staged": {
"*.ts": [
"prettier --write"
]
}
}

View file

@ -1,397 +0,0 @@
import {HtmlUtils} from "../WebRtc/HtmlUtils";
import type {UserInputManager} from "../Phaser/UserInput/UserInputManager";
import type {RoomConnection} from "../Connexion/RoomConnection";
import type {PlayGlobalMessageInterface} from "../Connexion/ConnexionModels";
import {AdminMessageEventTypes} from "../Connexion/AdminMessagesService";
export const CLASS_CONSOLE_MESSAGE = 'main-console';
export const INPUT_CONSOLE_MESSAGE = 'input-send-text';
export const UPLOAD_CONSOLE_MESSAGE = 'input-upload-music';
export const INPUT_TYPE_CONSOLE = 'input-type';
export const VIDEO_QUALITY_SELECT = 'select-video-quality';
export const AUDIO_TYPE = AdminMessageEventTypes.audio;
export const MESSAGE_TYPE = AdminMessageEventTypes.admin;
interface EventTargetFiles extends EventTarget {
files: Array<File>;
}
/**
* @deprecated
*/
export class ConsoleGlobalMessageManager {
private readonly divMainConsole: HTMLDivElement;
private readonly divMessageConsole: HTMLDivElement;
//private readonly divSettingConsole: HTMLDivElement;
private readonly buttonMainConsole: HTMLDivElement;
private readonly buttonSendMainConsole: HTMLImageElement;
//private readonly buttonAdminMainConsole: HTMLImageElement;
//private readonly buttonSettingsMainConsole: HTMLImageElement;
private activeConsole: boolean = false;
private activeMessage: boolean = false;
private activeSetting: boolean = false;
private userInputManager!: UserInputManager;
private static cssLoaded: boolean = false;
constructor(private Connection: RoomConnection, userInputManager : UserInputManager, private isAdmin: Boolean) {
this.buttonMainConsole = document.createElement('div');
this.buttonMainConsole.classList.add('console');
this.buttonMainConsole.hidden = true;
this.divMainConsole = document.createElement('div');
this.divMainConsole.className = CLASS_CONSOLE_MESSAGE;
this.divMessageConsole = document.createElement('div');
this.divMessageConsole.className = 'message';
//this.divSettingConsole = document.createElement('div');
//this.divSettingConsole.className = 'setting';
this.buttonSendMainConsole = document.createElement('img');
this.buttonSendMainConsole.id = 'btn-send-message';
//this.buttonSettingsMainConsole = document.createElement('img');
//this.buttonAdminMainConsole = document.createElement('img');
this.userInputManager = userInputManager;
this.initialise();
}
initialise() {
for (const elem of document.getElementsByClassName(CLASS_CONSOLE_MESSAGE)) {
elem.remove();
}
const typeConsole = document.createElement('input');
typeConsole.id = INPUT_TYPE_CONSOLE;
typeConsole.value = MESSAGE_TYPE;
typeConsole.type = 'hidden';
const menu = document.createElement('div');
menu.classList.add('menu')
const textMessage = document.createElement('span');
textMessage.innerText = "Message";
textMessage.classList.add('active');
textMessage.addEventListener('click', () => {
textMessage.classList.add('active');
const messageSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(INPUT_CONSOLE_MESSAGE));
messageSection.classList.add('active');
textAudio.classList.remove('active');
const audioSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(UPLOAD_CONSOLE_MESSAGE));
audioSection.classList.remove('active');
typeConsole.value = MESSAGE_TYPE;
});
menu.appendChild(textMessage);
const textAudio = document.createElement('span');
textAudio.innerText = "Audio";
textAudio.addEventListener('click', () => {
textAudio.classList.add('active');
const audioSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(UPLOAD_CONSOLE_MESSAGE));
audioSection.classList.add('active');
textMessage.classList.remove('active');
const messageSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(INPUT_CONSOLE_MESSAGE));
messageSection.classList.remove('active');
typeConsole.value = AUDIO_TYPE;
});
menu.appendChild(textMessage);
menu.appendChild(textAudio);
this.divMessageConsole.appendChild(menu);
this.buttonSendMainConsole.src = 'resources/logos/send-yellow.svg';
this.buttonSendMainConsole.addEventListener('click', () => {
if(this.activeMessage){
this.disabledMessageConsole();
}else{
this.activeMessageConsole();
}
});
/*this.buttonAdminMainConsole.src = 'resources/logos/setting-yellow.svg';
this.buttonAdminMainConsole.addEventListener('click', () => {
window.open(ADMIN_URL, '_blank');
});*/
/*this.buttonSettingsMainConsole.src = 'resources/logos/monitor-yellow.svg';
this.buttonSettingsMainConsole.addEventListener('click', () => {
if(this.activeSetting){
this.disabledSettingConsole();
}else{
this.activeSettingConsole();
}
});*/
this.divMessageConsole.appendChild(typeConsole);
/*if(this.isAdmin) {
this.buttonMainConsole.appendChild(this.buttonSendMainConsole);
//this.buttonMainConsole.appendChild(this.buttonAdminMainConsole);
}*/
this.createTextMessagePart();
this.createUploadAudioPart();
//this.buttonMainConsole.appendChild(this.buttonSettingsMainConsole);
this.divMainConsole.appendChild(this.buttonMainConsole);
this.divMainConsole.appendChild(this.divMessageConsole);
//this.divMainConsole.appendChild(this.divSettingConsole);
const mainSectionDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('main-container');
mainSectionDiv.appendChild(this.divMainConsole);
}
createTextMessagePart(){
const div = document.createElement('div');
div.id = INPUT_CONSOLE_MESSAGE
const buttonSend = document.createElement('button');
buttonSend.innerText = 'Send';
buttonSend.classList.add('btn');
buttonSend.addEventListener('click', (event: MouseEvent) => {
this.sendMessage();
this.disabledMessageConsole();
});
const buttonDiv = document.createElement('div');
buttonDiv.classList.add('btn-action');
buttonDiv.appendChild(buttonSend)
const section = document.createElement('section');
section.id = this.getSectionId(INPUT_CONSOLE_MESSAGE);
section.classList.add('active');
section.appendChild(div);
section.appendChild(buttonDiv);
this.divMessageConsole.appendChild(section);
(async () => {
try{
// Start loading CSS
const cssPromise = ConsoleGlobalMessageManager.loadCss();
// Import quill
const {default: Quill}:any = await import("quill"); // eslint-disable-line @typescript-eslint/no-explicit-any
// Wait for CSS to be loaded
await cssPromise;
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
[{'header': 1}, {'header': 2}], // custom button values
[{'list': 'ordered'}, {'list': 'bullet'}],
[{'script': 'sub'}, {'script': 'super'}], // superscript/subscript
[{'indent': '-1'}, {'indent': '+1'}], // outdent/indent
[{'direction': 'rtl'}], // text direction
[{'size': ['small', false, 'large', 'huge']}], // custom dropdown
[{'header': [1, 2, 3, 4, 5, 6, false]}],
[{'color': []}, {'background': []}], // dropdown with defaults from theme
[{'font': []}],
[{'align': []}],
['clean'],
['link', 'image', 'video']
// remove formatting button
];
new Quill(`#${INPUT_CONSOLE_MESSAGE}`, {
theme: 'snow',
modules: {
toolbar: toolbarOptions
},
});
}catch(err){
console.error(err);
}
})();
}
createUploadAudioPart(){
const div = document.createElement('div');
div.classList.add('upload');
const label = document.createElement('label');
label.setAttribute('for', UPLOAD_CONSOLE_MESSAGE);
const img = document.createElement('img');
img.setAttribute('for', UPLOAD_CONSOLE_MESSAGE);
img.src = 'resources/logos/music-file.svg';
const input = document.createElement('input');
input.type = 'file';
input.id = UPLOAD_CONSOLE_MESSAGE
input.addEventListener('input', (e: Event) => {
if(!e.target){
return;
}
const eventTarget : EventTargetFiles = (e.target as EventTargetFiles);
if(!eventTarget || !eventTarget.files || eventTarget.files.length === 0){
return;
}
const file : File = eventTarget.files[0];
if(!file){
return;
}
try {
HtmlUtils.removeElementByIdOrFail('audi-message-filename');
}catch (err) {
console.error(err)
}
const p = document.createElement('p');
p.id = 'audi-message-filename';
p.innerText = `${file.name} : ${this.getFileSize(file.size)}`;
label.appendChild(p);
});
label.appendChild(img);
div.appendChild(label);
div.appendChild(input);
const buttonSend = document.createElement('button');
buttonSend.innerText = 'Send';
buttonSend.classList.add('btn');
buttonSend.addEventListener('click', (event: MouseEvent) => {
this.sendMessage();
this.disabledMessageConsole();
});
const buttonDiv = document.createElement('div');
buttonDiv.classList.add('btn-action');
buttonDiv.appendChild(buttonSend)
const section = document.createElement('section');
section.id = this.getSectionId(UPLOAD_CONSOLE_MESSAGE);
section.appendChild(div);
section.appendChild(buttonDiv);
this.divMessageConsole.appendChild(section);
}
private static loadCss(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (ConsoleGlobalMessageManager.cssLoaded) {
resolve();
return;
}
const fileref = document.createElement("link")
fileref.setAttribute("rel", "stylesheet")
fileref.setAttribute("type", "text/css")
fileref.setAttribute("href", "https://cdn.quilljs.com/1.3.7/quill.snow.css");
document.getElementsByTagName("head")[0].appendChild(fileref);
ConsoleGlobalMessageManager.cssLoaded = true;
fileref.onload = () => {
resolve();
}
fileref.onerror = () => {
reject();
}
});
}
sendMessage(){
const inputType = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(INPUT_TYPE_CONSOLE);
if(AUDIO_TYPE !== inputType.value && MESSAGE_TYPE !== inputType.value){
throw "Error event type";
}
if(AUDIO_TYPE === inputType.value){
return this.sendAudioMessage();
}
return this.sendTextMessage();
}
private sendTextMessage(){
const elements = document.getElementsByClassName('ql-editor');
const quillEditor = elements.item(0);
if(!quillEditor){
throw "Error get quill node";
}
const GlobalMessage : PlayGlobalMessageInterface = {
id: "1", // FIXME: use another ID?
message: quillEditor.innerHTML,
type: MESSAGE_TYPE
};
quillEditor.innerHTML = '';
this.Connection.emitGlobalMessage(GlobalMessage);
}
private async sendAudioMessage(){
const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(UPLOAD_CONSOLE_MESSAGE);
const selectedFile = inputAudio.files ? inputAudio.files[0] : null;
if(!selectedFile){
throw 'no file selected';
}
const fd = new FormData();
fd.append('file', selectedFile);
const res = await this.Connection.uploadAudio(fd);
const GlobalMessage : PlayGlobalMessageInterface = {
id: (res as {id: string}).id,
message: (res as {path: string}).path,
type: AUDIO_TYPE
};
inputAudio.value = '';
try {
HtmlUtils.removeElementByIdOrFail('audi-message-filename');
}catch (err) {
console.error(err);
}
this.Connection.emitGlobalMessage(GlobalMessage);
}
active(){
this.userInputManager.disableControls();
this.divMainConsole.style.top = '0';
this.activeConsole = true;
}
disabled(){
this.userInputManager.initKeyBoardEvent();
this.activeConsole = false;
this.divMainConsole.style.top = '-80%';
}
activeMessageConsole(){
if(!this.isAdmin){
throw "User is not admin";
}
if(this.activeMessage){
this.disabledMessageConsole();
return;
}
this.activeMessage = true;
this.active();
this.divMessageConsole.classList.add('active');
this.buttonMainConsole.hidden = false;
this.buttonSendMainConsole.classList.add('active');
//if button not
try{
HtmlUtils.getElementByIdOrFail('btn-send-message');
}catch (e) {
this.buttonMainConsole.appendChild(this.buttonSendMainConsole);
}
}
disabledMessageConsole(){
this.activeMessage = false;
this.disabled();
this.buttonMainConsole.hidden = true;
this.divMessageConsole.classList.remove('active');
this.buttonSendMainConsole.classList.remove('active');
}
private getSectionId(id: string) : string {
return `section-${id}`;
}
private getFileSize(number: number) :string {
if (number < 1024) {
return number + 'bytes';
} else if (number >= 1024 && number < 1048576) {
return (number / 1024).toFixed(1) + 'KB';
} else if (number >= 1048576) {
return (number / 1048576).toFixed(1) + 'MB';
}else{
return '';
}
}
}

View file

@ -1,10 +1,10 @@
import {HtmlUtils} from "./../WebRtc/HtmlUtils";
import {AUDIO_TYPE, MESSAGE_TYPE} from "./ConsoleGlobalMessageManager";
import {PUSHER_URL, UPLOADER_URL} from "../Enum/EnvironmentVariable";
import type {RoomConnection} from "../Connexion/RoomConnection";
import type {PlayGlobalMessageInterface} from "../Connexion/ConnexionModels";
import {soundPlayingStore} from "../Stores/SoundPlayingStore";
import {soundManager} from "../Phaser/Game/SoundManager";
import {AdminMessageEventTypes} from "../Connexion/AdminMessagesService";
export class GlobalMessageManager {
@ -36,11 +36,11 @@ export class GlobalMessageManager {
previousMessage.remove();
}
if(AUDIO_TYPE === message.type){
if(AdminMessageEventTypes.audio === message.type){
this.playAudioMessage(message.id, message.message);
}
if(MESSAGE_TYPE === message.type){
if(AdminMessageEventTypes.admin === message.type){
this.playTextMessage(message.id, message.message);
}
}

View file

@ -0,0 +1,13 @@
import * as tg from "generic-type-guard";
export const isDataLayerEvent =
new tg.IsInterface().withProperties({
data: tg.isObject
}).get();
/**
* A message sent from the game to the iFrame when the data of the layers change after the iFrame send a message to the game that it want to listen to the data of the layers
*/
export type DataLayerEvent = tg.GuardedType<typeof isDataLayerEvent>;

View file

@ -0,0 +1,15 @@
import * as tg from "generic-type-guard";
export const isGameStateEvent =
new tg.IsInterface().withProperties({
roomId: tg.isString,
mapUrl: tg.isString,
nickname: tg.isUnion(tg.isString, tg.isNull),
uuid: tg.isUnion(tg.isString, tg.isUndefined),
startLayerName: tg.isUnion(tg.isString, tg.isNull),
tags : tg.isArray(tg.isString),
}).get();
/**
* A message sent from the game to the iFrame when the gameState is received by the script
*/
export type GameStateEvent = tg.GuardedType<typeof isGameStateEvent>;

View file

@ -0,0 +1,19 @@
import * as tg from "generic-type-guard";
export const isHasPlayerMovedEvent =
new tg.IsInterface().withProperties({
direction: tg.isElementOf('right', 'left', 'up', 'down'),
moving: tg.isBoolean,
x: tg.isNumber,
y: tg.isNumber
}).get();
/**
* A message sent from the game to the iFrame to notify a movement from the current player.
*/
export type HasPlayerMovedEvent = tg.GuardedType<typeof isHasPlayerMovedEvent>;
export type HasPlayerMovedEventCallback = (event: HasPlayerMovedEvent) => void

View file

@ -1,16 +1,23 @@
import type { GameStateEvent } from './GameStateEvent';
import type { ButtonClickedEvent } from './ButtonClickedEvent';
import type { ChatEvent } from './ChatEvent';
import type { ClosePopupEvent } from './ClosePopupEvent';
import type { EnterLeaveEvent } from './EnterLeaveEvent';
import type { GoToPageEvent } from './GoToPageEvent';
import type { LoadPageEvent } from './LoadPageEvent';
import type { OpenCoWebSiteEvent } from './OpenCoWebSiteEvent';
import type { OpenPopupEvent } from './OpenPopupEvent';
import type { OpenTabEvent } from './OpenTabEvent';
import type { UserInputChatEvent } from './UserInputChatEvent';
import type {LoadSoundEvent} from "./LoadSoundEvent";
import type {PlaySoundEvent} from "./PlaySoundEvent";
import type { DataLayerEvent } from "./DataLayerEvent";
import type { LayerEvent } from './LayerEvent';
import type { SetPropertyEvent } from "./setPropertyEvent";
import type { LoadSoundEvent } from "./LoadSoundEvent";
import type { PlaySoundEvent } from "./PlaySoundEvent";
import type { MenuItemClickedEvent } from "./ui/MenuItemClickedEvent";
import type { MenuItemRegisterEvent } from './ui/MenuItemRegisterEvent';
import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent";
export interface TypedMessageEvent<T> extends MessageEvent {
@ -20,6 +27,7 @@ export interface TypedMessageEvent<T> extends MessageEvent {
export type IframeEventMap = {
//getState: GameStateEvent,
// updateTile: UpdateTileEvent
loadPage: LoadPageEvent
chat: ChatEvent,
openPopup: OpenPopupEvent
closePopup: ClosePopupEvent
@ -31,9 +39,16 @@ export type IframeEventMap = {
restorePlayerControls: null
displayBubble: null
removeBubble: null
onPlayerMove: undefined
showLayer: LayerEvent
hideLayer: LayerEvent
setProperty: SetPropertyEvent
getDataLayer: undefined
loadSound: LoadSoundEvent
playSound: PlaySoundEvent
stopSound: null
stopSound: null,
getState: undefined,
registerMenuCommand: MenuItemRegisterEvent
}
export interface IframeEvent<T extends keyof IframeEventMap> {
type: T;
@ -49,7 +64,10 @@ export interface IframeResponseEventMap {
enterEvent: EnterLeaveEvent
leaveEvent: EnterLeaveEvent
buttonClickedEvent: ButtonClickedEvent
// gameState: GameStateEvent
gameState: GameStateEvent
hasPlayerMoved: HasPlayerMovedEvent
dataLayer: DataLayerEvent
menuItemClicked: MenuItemClickedEvent
}
export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> {
type: T;

View file

@ -0,0 +1,10 @@
import * as tg from "generic-type-guard";
export const isLayerEvent =
new tg.IsInterface().withProperties({
name: tg.isString,
}).get();
/**
* A message sent from the iFrame to the game to show/hide a layer.
*/
export type LayerEvent = tg.GuardedType<typeof isLayerEvent>;

View file

@ -0,0 +1,13 @@
import * as tg from "generic-type-guard";
export const isLoadPageEvent =
new tg.IsInterface().withProperties({
url: tg.isString,
}).get();
/**
* A message sent from the iFrame to the game to add a message in the chat.
*/
export type LoadPageEvent = tg.GuardedType<typeof isLoadPageEvent>;

View file

@ -0,0 +1,12 @@
import * as tg from "generic-type-guard";
export const isSetPropertyEvent =
new tg.IsInterface().withProperties({
layerName: tg.isString,
propertyName: tg.isString,
propertyValue: tg.isUnion(tg.isString, tg.isUnion(tg.isNumber, tg.isUnion(tg.isBoolean, tg.isUndefined)))
}).get();
/**
* A message sent from the iFrame to the game to change the value of the property of the layer
*/
export type SetPropertyEvent = tg.GuardedType<typeof isSetPropertyEvent>;

View file

@ -0,0 +1,12 @@
import * as tg from "generic-type-guard";
export const isMenuItemClickedEvent =
new tg.IsInterface().withProperties({
menuItem: tg.isString
}).get();
/**
* A message sent from the game to the iFrame when a menu item is clicked.
*/
export type MenuItemClickedEvent = tg.GuardedType<typeof isMenuItemClickedEvent>;

View file

@ -0,0 +1,25 @@
import * as tg from "generic-type-guard";
import { Subject } from 'rxjs';
export const isMenuItemRegisterEvent =
new tg.IsInterface().withProperties({
menutItem: tg.isString
}).get();
/**
* A message sent from the iFrame to the game to add a new menu item.
*/
export type MenuItemRegisterEvent = tg.GuardedType<typeof isMenuItemRegisterEvent>;
export const isMenuItemRegisterIframeEvent =
new tg.IsInterface().withProperties({
type: tg.isSingletonString("registerMenuCommand"),
data: isMenuItemRegisterEvent
}).get();
const _registerMenuCommandStream: Subject<string> = new Subject();
export const registerMenuCommandStream = _registerMenuCommandStream.asObservable();
export function handleMenuItemRegistrationEvent(event: MenuItemRegisterEvent) {
_registerMenuCommandStream.next(event.menutItem)
}

View file

@ -1,25 +1,42 @@
import { Subject } from "rxjs";
import { ChatEvent, isChatEvent } from "./Events/ChatEvent";
import { HtmlUtils } from "../WebRtc/HtmlUtils";
import type { EnterLeaveEvent } from "./Events/EnterLeaveEvent";
import { isOpenPopupEvent, OpenPopupEvent } from "./Events/OpenPopupEvent";
import { isOpenTabEvent, OpenTabEvent } from "./Events/OpenTabEvent";
import type { ButtonClickedEvent } from "./Events/ButtonClickedEvent";
import { ClosePopupEvent, isClosePopupEvent } from "./Events/ClosePopupEvent";
import { scriptUtils } from "./ScriptUtils";
import { GoToPageEvent, isGoToPageEvent } from "./Events/GoToPageEvent";
import { isOpenCoWebsite, OpenCoWebSiteEvent } from "./Events/OpenCoWebSiteEvent";
import { IframeEventMap, IframeEvent, IframeResponseEvent, IframeResponseEventMap, isIframeEventWrapper, TypedMessageEvent } from "./Events/IframeEvent";
import type { UserInputChatEvent } from "./Events/UserInputChatEvent";
import {Subject} from "rxjs";
import {ChatEvent, isChatEvent} from "./Events/ChatEvent";
import {HtmlUtils} from "../WebRtc/HtmlUtils";
import type {EnterLeaveEvent} from "./Events/EnterLeaveEvent";
import {isOpenPopupEvent, OpenPopupEvent} from "./Events/OpenPopupEvent";
import {isOpenTabEvent, OpenTabEvent} from "./Events/OpenTabEvent";
import type {ButtonClickedEvent} from "./Events/ButtonClickedEvent";
import {ClosePopupEvent, isClosePopupEvent} from "./Events/ClosePopupEvent";
import {scriptUtils} from "./ScriptUtils";
import {GoToPageEvent, isGoToPageEvent} from "./Events/GoToPageEvent";
import {isOpenCoWebsite, OpenCoWebSiteEvent} from "./Events/OpenCoWebSiteEvent";
import {
IframeEvent,
IframeEventMap,
IframeResponseEvent,
IframeResponseEventMap,
isIframeEventWrapper,
TypedMessageEvent
} from "./Events/IframeEvent";
import type {UserInputChatEvent} from "./Events/UserInputChatEvent";
//import { isLoadPageEvent } from './Events/LoadPageEvent';
import {isPlaySoundEvent, PlaySoundEvent} from "./Events/PlaySoundEvent";
import {isStopSoundEvent, StopSoundEvent} from "./Events/StopSoundEvent";
import {isLoadSoundEvent, LoadSoundEvent} from "./Events/LoadSoundEvent";
import {isSetPropertyEvent, SetPropertyEvent} from "./Events/setPropertyEvent";
import {isLayerEvent, LayerEvent} from "./Events/LayerEvent";
import {isMenuItemRegisterEvent,} from "./Events/ui/MenuItemRegisterEvent";
import type {DataLayerEvent} from "./Events/DataLayerEvent";
import type {GameStateEvent} from "./Events/GameStateEvent";
import type {HasPlayerMovedEvent} from "./Events/HasPlayerMovedEvent";
import {isLoadPageEvent} from "./Events/LoadPageEvent";
import {handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent} from "./Events/ui/MenuItemRegisterEvent";
/**
* Listens to messages from iframes and turn those messages into easy to use observables.
* Also allows to send messages to those iframes.
*/
class IframeListener {
private readonly _chatStream: Subject<ChatEvent> = new Subject();
public readonly chatStream = this._chatStream.asObservable();
@ -32,6 +49,9 @@ class IframeListener {
private readonly _goToPageStream: Subject<GoToPageEvent> = new Subject();
public readonly goToPageStream = this._goToPageStream.asObservable();
private readonly _loadPageStream: Subject<string> = new Subject();
public readonly loadPageStream = this._loadPageStream.asObservable();
private readonly _openCoWebSiteStream: Subject<OpenCoWebSiteEvent> = new Subject();
public readonly openCoWebSiteStream = this._openCoWebSiteStream.asObservable();
@ -53,6 +73,27 @@ class IframeListener {
private readonly _removeBubbleStream: Subject<void> = new Subject();
public readonly removeBubbleStream = this._removeBubbleStream.asObservable();
private readonly _showLayerStream: Subject<LayerEvent> = new Subject();
public readonly showLayerStream = this._showLayerStream.asObservable();
private readonly _hideLayerStream: Subject<LayerEvent> = new Subject();
public readonly hideLayerStream = this._hideLayerStream.asObservable();
private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject();
public readonly setPropertyStream = this._setPropertyStream.asObservable();
private readonly _gameStateStream: Subject<void> = new Subject();
public readonly gameStateStream = this._gameStateStream.asObservable();
private readonly _dataLayerChangeStream: Subject<void> = new Subject();
public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable();
private readonly _registerMenuCommandStream: Subject<string> = new Subject();
public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable();
private readonly _unregisterMenuCommandStream: Subject<string> = new Subject();
public readonly unregisterMenuCommandStream = this._unregisterMenuCommandStream.asObservable();
private readonly _playSoundStream: Subject<PlaySoundEvent> = new Subject();
public readonly playSoundStream = this._playSoundStream.asObservable();
@ -63,27 +104,38 @@ class IframeListener {
public readonly loadSoundStream = this._loadSoundStream.asObservable();
private readonly iframes = new Set<HTMLIFrameElement>();
private readonly iframeCloseCallbacks = new Map<HTMLIFrameElement, (() => void)[]>();
private readonly scripts = new Map<string, HTMLIFrameElement>();
private sendPlayerMove: boolean = false;
init() {
window.addEventListener("message", (message: TypedMessageEvent<IframeEvent<keyof IframeEventMap>>) => {
// Do we trust the sender of this message?
// Let's only accept messages from the iframe that are allowed.
// Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain).
let foundSrc: string | null = null;
for (const iframe of this.iframes) {
let foundSrc: string | undefined;
let iframe: HTMLIFrameElement;
for (iframe of this.iframes) {
if (iframe.contentWindow === message.source) {
foundSrc = iframe.src;
break;
}
}
if (!foundSrc) {
if (foundSrc === undefined) {
return;
}
const payload = message.data;
if (isIframeEventWrapper(payload)) {
if (payload.type === 'chat' && isChatEvent(payload.data)) {
if (payload.type === 'showLayer' && isLayerEvent(payload.data)) {
this._showLayerStream.next(payload.data);
} else if (payload.type === 'hideLayer' && isLayerEvent(payload.data)) {
this._hideLayerStream.next(payload.data);
} else if (payload.type === 'setProperty' && isSetPropertyEvent(payload.data)) {
this._setPropertyStream.next(payload.data);
} else if (payload.type === 'chat' && isChatEvent(payload.data)) {
this._chatStream.next(payload.data);
} else if (payload.type === 'openPopup' && isOpenPopupEvent(payload.data)) {
this._openPopupStream.next(payload.data);
@ -96,6 +148,9 @@ class IframeListener {
else if (payload.type === 'goToPage' && isGoToPageEvent(payload.data)) {
scriptUtils.goToPage(payload.data.url);
}
else if (payload.type === 'loadPage' && isLoadPageEvent(payload.data)) {
this._loadPageStream.next(payload.data.url);
}
else if (payload.type === 'playSound' && isPlaySoundEvent(payload.data)) {
this._playSoundStream.next(payload.data);
}
@ -106,11 +161,7 @@ class IframeListener {
this._loadSoundStream.next(payload.data);
}
else if (payload.type === 'openCoWebSite' && isOpenCoWebsite(payload.data)) {
const scriptUrl = [...this.scripts.keys()].find(key => {
return this.scripts.get(key)?.contentWindow == message.source
})
scriptUtils.openCoWebsite(payload.data.url, scriptUrl || foundSrc);
scriptUtils.openCoWebsite(payload.data.url, foundSrc);
}
else if (payload.type === 'closeCoWebSite') {
@ -122,28 +173,56 @@ class IframeListener {
}
else if (payload.type === 'restorePlayerControls') {
this._enablePlayerControlStream.next();
}
else if (payload.type === 'displayBubble') {
} else if (payload.type === 'displayBubble') {
this._displayBubbleStream.next();
}
else if (payload.type === 'removeBubble') {
} else if (payload.type === 'removeBubble') {
this._removeBubbleStream.next();
} else if (payload.type == "getState") {
this._gameStateStream.next();
} else if (payload.type == "onPlayerMove") {
this.sendPlayerMove = true
} else if (payload.type == "getDataLayer") {
this._dataLayerChangeStream.next();
} else if (isMenuItemRegisterIframeEvent(payload)) {
const data = payload.data.menutItem;
// @ts-ignore
this.iframeCloseCallbacks.get(iframe).push(() => {
this._unregisterMenuCommandStream.next(data);
})
handleMenuItemRegistrationEvent(payload.data)
}
}
}, false);
}
sendDataLayerEvent(dataLayerEvent: DataLayerEvent) {
this.postMessage({
'type' : 'dataLayer',
'data' : dataLayerEvent
})
}
sendGameStateEvent(gameStateEvent: GameStateEvent) {
this.postMessage({
'type': 'gameState',
'data': gameStateEvent
});
}
/**
* Allows the passed iFrame to send/receive messages via the API.
*/
registerIframe(iframe: HTMLIFrameElement): void {
this.iframes.add(iframe);
this.iframeCloseCallbacks.set(iframe, []);
}
unregisterIframe(iframe: HTMLIFrameElement): void {
this.iframeCloseCallbacks.get(iframe)?.forEach(callback => {
callback();
});
this.iframes.delete(iframe);
}
@ -153,7 +232,7 @@ class IframeListener {
if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') {
// Using external iframe mode (
const iframe = document.createElement('iframe');
iframe.id = this.getIFrameId(scriptUrl);
iframe.id = IframeListener.getIFrameId(scriptUrl);
iframe.style.display = 'none';
iframe.src = '/iframe.html?script=' + encodeURIComponent(scriptUrl);
@ -168,25 +247,24 @@ class IframeListener {
} else {
// production code
const iframe = document.createElement('iframe');
iframe.id = this.getIFrameId(scriptUrl);
iframe.id = IframeListener.getIFrameId(scriptUrl);
iframe.style.display = 'none';
// We are putting a sandbox on this script because it will run in the same domain as the main website.
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-top-navigation-by-user-activation');
const html = '<!doctype html>\n' +
//iframe.src = "data:text/html;charset=utf-8," + escape(html);
iframe.srcdoc = '<!doctype html>\n' +
'\n' +
'<html lang="en">\n' +
'<head>\n' +
'<script src="' + window.location.protocol + '//' + window.location.host + '/iframe_api.js" ></script>\n' +
'<script src="' + scriptUrl + '" ></script>\n' +
'<title></title>\n' +
'</head>\n' +
'</html>\n';
//iframe.src = "data:text/html;charset=utf-8," + escape(html);
iframe.srcdoc = html;
document.body.prepend(iframe);
this.scripts.set(scriptUrl, iframe);
@ -196,12 +274,12 @@ class IframeListener {
}
private getIFrameId(scriptUrl: string): string {
private static getIFrameId(scriptUrl: string): string {
return 'script' + btoa(scriptUrl);
}
unregisterScript(scriptUrl: string): void {
const iFrameId = this.getIFrameId(scriptUrl);
const iFrameId = IframeListener.getIFrameId(scriptUrl);
const iframe = HtmlUtils.getElementByIdOrFail<HTMLIFrameElement>(iFrameId);
if (!iframe) {
throw new Error('Unknown iframe for script "' + scriptUrl + '"');
@ -239,6 +317,15 @@ class IframeListener {
});
}
hasPlayerMoved(event: HasPlayerMovedEvent) {
if (this.sendPlayerMove) {
this.postMessage({
'type': 'hasPlayerMoved',
'data': event
});
}
}
sendButtonClickedEvent(popupId: number, buttonId: number): void {
this.postMessage({
'type': 'buttonClickedEvent',
@ -252,7 +339,7 @@ class IframeListener {
/**
* Sends the message... to all allowed iframes.
*/
private postMessage(message: IframeResponseEvent<keyof IframeResponseEventMap>) {
public postMessage(message: IframeResponseEvent<keyof IframeResponseEventMap>) {
for (const iframe of this.iframes) {
iframe.contentWindow?.postMessage(message, '*');
}

View file

@ -0,0 +1,31 @@
import type * as tg from "generic-type-guard";
import type { IframeEvent, IframeEventMap, IframeResponseEventMap } from '../Events/IframeEvent';
export function sendToWorkadventure(content: IframeEvent<keyof IframeEventMap>) {
window.parent.postMessage(content, "*")
}
type GuardedType<Guard extends tg.TypeGuard<unknown>> = Guard extends tg.TypeGuard<infer T> ? T : never
export interface IframeCallback<Key extends keyof IframeResponseEventMap, T = IframeResponseEventMap[Key], Guard = tg.TypeGuard<T>> {
typeChecker: Guard,
callback: (payloadData: T) => void
}
export interface IframeCallbackContribution<Key extends keyof IframeResponseEventMap> extends IframeCallback<Key> {
type: Key
}
/**
* !! be aware that the implemented attributes (addMethodsAtRoot and subObjectIdentifier) must be readonly
*
*
*/
export abstract class IframeApiContribution<T extends {
callbacks: Array<IframeCallbackContribution<keyof IframeResponseEventMap>>,
}> {
abstract callbacks: T["callbacks"]
}

View file

@ -0,0 +1,39 @@
import {sendToWorkadventure} from "../IframeApiContribution";
import type {LoadSoundEvent} from "../../Events/LoadSoundEvent";
import type {PlaySoundEvent} from "../../Events/PlaySoundEvent";
import type {StopSoundEvent} from "../../Events/StopSoundEvent";
import SoundConfig = Phaser.Types.Sound.SoundConfig;
export class Sound {
constructor(private url: string) {
sendToWorkadventure({
"type": 'loadSound',
"data": {
url: this.url,
} as LoadSoundEvent
});
}
public play(config: SoundConfig) {
sendToWorkadventure({
"type": 'playSound',
"data": {
url: this.url,
config
} as PlaySoundEvent
});
return this.url;
}
public stop() {
sendToWorkadventure({
"type": 'stopSound',
"data": {
url: this.url,
} as StopSoundEvent
});
return this.url;
}
}

View file

@ -0,0 +1,18 @@
import type {Popup} from "./Popup";
export type ButtonClickedCallback = (popup: Popup) => void;
export interface ButtonDescriptor {
/**
* The label of the button
*/
label: string,
/**
* The type of the button. Can be one of "normal", "primary", "success", "warning", "error", "disabled"
*/
className?: "normal" | "primary" | "success" | "warning" | "error" | "disabled",
/**
* Callback called if the button is pressed
*/
callback: ButtonClickedCallback,
}

View file

@ -0,0 +1,11 @@
import type { MenuItemClickedEvent } from '../../Events/ui/MenuItemClickedEvent';
import { iframeListener } from '../../IframeListener';
export function sendMenuClickedEvent(menuItem: string) {
iframeListener.postMessage({
'type': 'menuItemClicked',
'data': {
menuItem: menuItem,
} as MenuItemClickedEvent
});
}

View file

@ -0,0 +1,19 @@
import {sendToWorkadventure} from "../IframeApiContribution";
import type {ClosePopupEvent} from "../../Events/ClosePopupEvent";
export class Popup {
constructor(private id: number) {
}
/**
* Closes the popup
*/
public close(): void {
sendToWorkadventure({
'type': 'closePopup',
'data': {
'popupId': this.id,
} as ClosePopupEvent
});
}
}

View file

@ -0,0 +1,38 @@
import type { ChatEvent } from '../Events/ChatEvent'
import { isUserInputChatEvent, UserInputChatEvent } from '../Events/UserInputChatEvent'
import { IframeApiContribution, sendToWorkadventure } from './IframeApiContribution'
import { apiCallback } from "./registeredCallbacks";
import {Subject} from "rxjs";
const chatStream = new Subject<string>();
class WorkadventureChatCommands extends IframeApiContribution<WorkadventureChatCommands> {
callbacks = [apiCallback({
callback: (event: UserInputChatEvent) => {
chatStream.next(event.message);
},
type: "userInputChat",
typeChecker: isUserInputChatEvent
})]
sendChatMessage(message: string, author: string) {
sendToWorkadventure({
type: 'chat',
data: {
'message': message,
'author': author
}
})
}
/**
* Listen to messages sent by the local user, in the chat.
*/
onChatMessage(callback: (message: string) => void) {
chatStream.subscribe(callback);
}
}
export default new WorkadventureChatCommands()

View file

@ -0,0 +1,16 @@
import { IframeApiContribution, sendToWorkadventure } from './IframeApiContribution';
class WorkadventureControlsCommands extends IframeApiContribution<WorkadventureControlsCommands> {
callbacks = []
disablePlayerControls(): void {
sendToWorkadventure({ 'type': 'disablePlayerControls', data: null });
}
restorePlayerControls(): void {
sendToWorkadventure({ 'type': 'restorePlayerControls', data: null });
}
}
export default new WorkadventureControlsCommands();

View file

@ -0,0 +1,57 @@
import type { GoToPageEvent } from '../Events/GoToPageEvent';
import type { OpenTabEvent } from '../Events/OpenTabEvent';
import { IframeApiContribution, sendToWorkadventure } from './IframeApiContribution';
import type {OpenCoWebSiteEvent} from "../Events/OpenCoWebSiteEvent";
import type {LoadPageEvent} from "../Events/LoadPageEvent";
class WorkadventureNavigationCommands extends IframeApiContribution<WorkadventureNavigationCommands> {
callbacks = []
openTab(url: string): void {
sendToWorkadventure({
"type": 'openTab',
"data": {
url
}
});
}
goToPage(url: string): void {
sendToWorkadventure({
"type": 'goToPage',
"data": {
url
}
});
}
goToRoom(url: string): void {
sendToWorkadventure({
"type": 'loadPage',
"data": {
url
}
});
}
openCoWebSite(url: string): void {
sendToWorkadventure({
"type": 'openCoWebSite',
"data": {
url
}
});
}
closeCoWebSite(): void {
sendToWorkadventure({
"type": 'closeCoWebSite',
data: null
});
}
}
export default new WorkadventureNavigationCommands();

View file

@ -0,0 +1,29 @@
import {IframeApiContribution, sendToWorkadventure} from "./IframeApiContribution";
import type {HasPlayerMovedEvent, HasPlayerMovedEventCallback} from "../Events/HasPlayerMovedEvent";
import {Subject} from "rxjs";
import {apiCallback} from "./registeredCallbacks";
import {isHasPlayerMovedEvent} from "../Events/HasPlayerMovedEvent";
const moveStream = new Subject<HasPlayerMovedEvent>();
class WorkadventurePlayerCommands extends IframeApiContribution<WorkadventurePlayerCommands> {
callbacks = [
apiCallback({
type: 'hasPlayerMoved',
typeChecker: isHasPlayerMovedEvent,
callback: (payloadData) => {
moveStream.next(payloadData);
}
}),
]
onPlayerMove(callback: HasPlayerMovedEventCallback): void {
moveStream.subscribe(callback);
sendToWorkadventure({
type: 'onPlayerMove',
data: null
})
}
}
export default new WorkadventurePlayerCommands();

View file

@ -0,0 +1,16 @@
import type {IframeResponseEventMap} from "../../Api/Events/IframeEvent";
import type {IframeCallback} from "../../Api/iframe/IframeApiContribution";
import type {IframeCallbackContribution} from "../../Api/iframe/IframeApiContribution";
export const registeredCallbacks: { [K in keyof IframeResponseEventMap]?: IframeCallback<K> } = {}
export function apiCallback<T extends keyof IframeResponseEventMap>(callbackData: IframeCallbackContribution<T>): IframeCallbackContribution<keyof IframeResponseEventMap> {
const iframeCallback = {
typeChecker: callbackData.typeChecker,
callback: callbackData.callback
} as IframeCallback<T>;
const newCallback = { [callbackData.type]: iframeCallback };
Object.assign(registeredCallbacks, newCallback)
return callbackData as unknown as IframeCallbackContribution<keyof IframeResponseEventMap>;
}

View file

@ -0,0 +1,135 @@
import { Subject } from "rxjs";
import { EnterLeaveEvent, isEnterLeaveEvent } from '../Events/EnterLeaveEvent';
import {IframeApiContribution, sendToWorkadventure} from './IframeApiContribution';
import { apiCallback } from "./registeredCallbacks";
import type {LayerEvent} from "../Events/LayerEvent";
import type {SetPropertyEvent} from "../Events/setPropertyEvent";
import type {GameStateEvent} from "../Events/GameStateEvent";
import type {ITiledMap} from "../../Phaser/Map/ITiledMap";
import type {DataLayerEvent} from "../Events/DataLayerEvent";
import {isGameStateEvent} from "../Events/GameStateEvent";
import {isDataLayerEvent} from "../Events/DataLayerEvent";
const enterStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const dataLayerResolver = new Subject<DataLayerEvent>();
const stateResolvers = new Subject<GameStateEvent>();
let immutableData: GameStateEvent;
interface Room {
id: string,
mapUrl: string,
map: ITiledMap,
startLayer: string | null
}
interface User {
id: string | undefined,
nickName: string | null,
tags: string[]
}
function getGameState(): Promise<GameStateEvent> {
if (immutableData) {
return Promise.resolve(immutableData);
}
else {
return new Promise<GameStateEvent>((resolver, thrower) => {
stateResolvers.subscribe(resolver);
sendToWorkadventure({type: "getState", data: null});
})
}
}
function getDataLayer(): Promise<DataLayerEvent> {
return new Promise<DataLayerEvent>((resolver, thrower) => {
dataLayerResolver.subscribe(resolver);
sendToWorkadventure({type: "getDataLayer", data: null})
})
}
class WorkadventureRoomCommands extends IframeApiContribution<WorkadventureRoomCommands> {
callbacks = [
apiCallback({
callback: (payloadData: EnterLeaveEvent) => {
enterStreams.get(payloadData.name)?.next();
},
type: "enterEvent",
typeChecker: isEnterLeaveEvent
}),
apiCallback({
type: "leaveEvent",
typeChecker: isEnterLeaveEvent,
callback: (payloadData) => {
leaveStreams.get(payloadData.name)?.next();
}
}),
apiCallback({
type: "gameState",
typeChecker: isGameStateEvent,
callback: (payloadData) => {
stateResolvers.next(payloadData);
}
}),
apiCallback({
type: "dataLayer",
typeChecker: isDataLayerEvent,
callback: (payloadData) => {
dataLayerResolver.next(payloadData);
}
}),
]
onEnterZone(name: string, callback: () => void): void {
let subject = enterStreams.get(name);
if (subject === undefined) {
subject = new Subject<EnterLeaveEvent>();
enterStreams.set(name, subject);
}
subject.subscribe(callback);
}
onLeaveZone(name: string, callback: () => void): void {
let subject = leaveStreams.get(name);
if (subject === undefined) {
subject = new Subject<EnterLeaveEvent>();
leaveStreams.set(name, subject);
}
subject.subscribe(callback);
}
showLayer(layerName: string): void {
sendToWorkadventure({type: 'showLayer', data: {'name': layerName}});
}
hideLayer(layerName: string): void {
sendToWorkadventure({type: 'hideLayer', data: {'name': layerName}});
}
setProperty(layerName: string, propertyName: string, propertyValue: string | number | boolean | undefined): void {
sendToWorkadventure({
type: 'setProperty',
data: {
'layerName': layerName,
'propertyName': propertyName,
'propertyValue': propertyValue,
}
})
}
getCurrentRoom(): Promise<Room> {
return getGameState().then((gameState) => {
return getDataLayer().then((mapJson) => {
return {id: gameState.roomId, map: mapJson.data as ITiledMap, mapUrl: gameState.mapUrl, startLayer: gameState.startLayerName};
})
})
}
getCurrentUser(): Promise<User> {
return getGameState().then((gameState) => {
return {id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags};
})
}
}
export default new WorkadventureRoomCommands();

View file

@ -0,0 +1,17 @@
import type { LoadSoundEvent } from '../Events/LoadSoundEvent';
import type { PlaySoundEvent } from '../Events/PlaySoundEvent';
import type { StopSoundEvent } from '../Events/StopSoundEvent';
import { IframeApiContribution, sendToWorkadventure } from './IframeApiContribution';
import {Sound} from "./Sound/Sound";
class WorkadventureSoundCommands extends IframeApiContribution<WorkadventureSoundCommands> {
callbacks = []
loadSound(url: string): Sound {
return new Sound(url);
}
}
export default new WorkadventureSoundCommands();

106
front/src/Api/iframe/ui.ts Normal file
View file

@ -0,0 +1,106 @@
import { isButtonClickedEvent } from '../Events/ButtonClickedEvent';
import { isMenuItemClickedEvent } from '../Events/ui/MenuItemClickedEvent';
import type { MenuItemRegisterEvent } from '../Events/ui/MenuItemRegisterEvent';
import { IframeApiContribution, sendToWorkadventure } from './IframeApiContribution';
import { apiCallback } from "./registeredCallbacks";
import type { ButtonClickedCallback, ButtonDescriptor } from "./Ui/ButtonDescriptor";
import { Popup } from "./Ui/Popup";
let popupId = 0;
const popups: Map<number, Popup> = new Map<number, Popup>();
const popupCallbacks: Map<number, Map<number, ButtonClickedCallback>> = new Map<number, Map<number, ButtonClickedCallback>>();
const menuCallbacks: Map<string, (command: string) => void> = new Map()
interface ZonedPopupOptions {
zone: string
objectLayerName?: string,
popupText: string,
delay?: number
popupOptions: Array<ButtonDescriptor>
}
class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventureUiCommands> {
callbacks = [apiCallback({
type: "buttonClickedEvent",
typeChecker: isButtonClickedEvent,
callback: (payloadData) => {
const callback = popupCallbacks.get(payloadData.popupId)?.get(payloadData.buttonId);
const popup = popups.get(payloadData.popupId);
if (popup === undefined) {
throw new Error('Could not find popup with ID "' + payloadData.popupId + '"');
}
if (callback) {
callback(popup);
}
}
}),
apiCallback({
type: "menuItemClicked",
typeChecker: isMenuItemClickedEvent,
callback: event => {
const callback = menuCallbacks.get(event.menuItem);
if (callback) {
callback(event.menuItem)
}
}
})];
openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup {
popupId++;
const popup = new Popup(popupId);
const btnMap = new Map<number, () => void>();
popupCallbacks.set(popupId, btnMap);
let id = 0;
for (const button of buttons) {
const callback = button.callback;
if (callback) {
btnMap.set(id, () => {
callback(popup);
});
}
id++;
}
sendToWorkadventure({
'type': 'openPopup',
'data': {
popupId,
targetObject,
message,
buttons: buttons.map((button) => {
return {
label: button.label,
className: button.className
};
})
}
});
popups.set(popupId, popup)
return popup;
}
registerMenuCommand(commandDescriptor: string, callback: (commandDescriptor: string) => void) {
menuCallbacks.set(commandDescriptor, callback);
sendToWorkadventure({
'type': 'registerMenuCommand',
'data': {
menutItem: commandDescriptor
}
});
}
displayBubble(): void {
sendToWorkadventure({ 'type': 'displayBubble', data: null });
}
removeBubble(): void {
sendToWorkadventure({ 'type': 'removeBubble', data: null });
}
}
export default new WorkAdventureUiCommands();

View file

@ -1,6 +1,4 @@
<script lang="typescript">
import MenuIcon from "./Menu/MenuIcon.svelte";
import {menuIconVisible} from "../Stores/MenuStore";
import {enableCameraSceneVisibilityStore, gameOverlayVisibilityStore} from "../Stores/MediaStore";
import CameraControls from "./CameraControls.svelte";
import MyCamera from "./MyCamera.svelte";
@ -9,6 +7,7 @@
import {selectCharacterSceneVisibleStore} from "../Stores/SelectCharacterStore";
import SelectCharacterScene from "./selectCharacter/SelectCharacterScene.svelte";
import {customCharacterSceneVisibleStore} from "../Stores/CustomCharacterStore";
import {errorStore} from "../Stores/ErrorStore";
import CustomCharacterScene from "./CustomCharacterScene/CustomCharacterScene.svelte";
import LoginScene from "./Login/LoginScene.svelte";
import {loginSceneVisibleStore} from "../Stores/LoginSceneStore";
@ -16,11 +15,14 @@
import VisitCard from "./VisitCard/VisitCard.svelte";
import {requestVisitCardsStore} from "../Stores/GameStore";
import {Game} from "../Phaser/Game/Game";
import type {Game} from "../Phaser/Game/Game";
import {helpCameraSettingsVisibleStore} from "../Stores/HelpCameraSettingsStore";
import HelpCameraSettingsPopup from "./HelpCameraSettings/HelpCameraSettingsPopup.svelte";
import AudioPlaying from "./UI/AudioPlaying.svelte";
import {soundPlayingStore} from "../Stores/SoundPlayingStore";
import ErrorDialog from "./UI/ErrorDialog.svelte";
import {consoleGlobalMessageManagerVisibleStore} from "../Stores/ConsoleGlobalMessageManagerStore";
import ConsoleGlobalMessageManager from "./ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte";
export let game: Game;
</script>
@ -70,12 +72,22 @@
<CameraControls></CameraControls>
</div>
{/if}
{#if $consoleGlobalMessageManagerVisibleStore}
<div>
<ConsoleGlobalMessageManager game={game}></ConsoleGlobalMessageManager>
</div>
{/if}
{#if $helpCameraSettingsVisibleStore}
<div>
<HelpCameraSettingsPopup game={ game }></HelpCameraSettingsPopup>
<HelpCameraSettingsPopup></HelpCameraSettingsPopup>
</div>
{/if}
{#if $requestVisitCardsStore}
<VisitCard visitCardUrl={$requestVisitCardsStore}></VisitCard>
{/if}
{#if $errorStore.length > 0}
<div>
<ErrorDialog></ErrorDialog>
</div>
{/if}
</div>

View file

@ -0,0 +1,44 @@
<script lang="typescript">
import InputTextGlobalMessage from "./InputTextGlobalMessage.svelte";
import UploadAudioGlobalMessage from "./UploadAudioGlobalMessage.svelte";
import {gameManager} from "../../Phaser/Game/GameManager";
import type {Game} from "../../Phaser/Game/Game";
export let game: Game;
let inputSendTextActive = true;
let uploadMusicActive = false;
function inputSendTextActivate() {
inputSendTextActive = true;
uploadMusicActive = false;
}
function inputUploadMusicActivate() {
uploadMusicActive = true;
inputSendTextActive = false;
}
</script>
<div class="main-console nes-container is-rounded">
<!-- <div class="console nes-container is-rounded">
<img class="btn-close" src="resources/logos/send-yellow.svg" alt="Close">
</div>-->
<div class="main-global-message">
<h2> Global Message </h2>
<div class="global-message">
<div class="menu">
<button class="nes-btn {inputSendTextActive ? 'is-disabled' : ''}" on:click|preventDefault={inputSendTextActivate}>Message</button>
<button class="nes-btn {uploadMusicActive ? 'is-disabled' : ''}" on:click|preventDefault={inputUploadMusicActivate}>Audio</button>
</div>
<div class="main-input">
{#if inputSendTextActive}
<InputTextGlobalMessage game={game} gameManager={gameManager}></InputTextGlobalMessage>
{/if}
{#if uploadMusicActive}
<UploadAudioGlobalMessage game={game} gameManager={gameManager}></UploadAudioGlobalMessage>
{/if}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import {consoleGlobalMessageManagerFocusStore, consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore";
import {onMount} from "svelte";
import type {Game} from "../../Phaser/Game/Game";
import type {GameManager} from "../../Phaser/Game/GameManager";
import type {PlayGlobalMessageInterface} from "../../Connexion/ConnexionModels";
import {AdminMessageEventTypes} from "../../Connexion/AdminMessagesService";
import type {Quill} from "quill";
import {LoginSceneName} from "../../Phaser/Login/LoginScene";
//toolbar
export const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
[{'header': 1}, {'header': 2}], // custom button values
[{'list': 'ordered'}, {'list': 'bullet'}],
[{'script': 'sub'}, {'script': 'super'}], // superscript/subscript
[{'indent': '-1'}, {'indent': '+1'}], // outdent/indent
[{'direction': 'rtl'}], // text direction
[{'size': ['small', false, 'large', 'huge']}], // custom dropdown
[{'header': [1, 2, 3, 4, 5, 6, false]}],
[{'color': []}, {'background': []}], // dropdown with defaults from theme
[{'font': []}],
[{'align': []}],
['clean'],
['link', 'image', 'video']
// remove formatting button
];
export let game: Game;
export let gameManager: GameManager;
let gameScene = gameManager.getCurrentGameScene(game.scene.getScene(LoginSceneName));
let quill: Quill;
let INPUT_CONSOLE_MESSAGE: HTMLDivElement;
const MESSAGE_TYPE = AdminMessageEventTypes.admin;
//Quill
onMount(async () => {
// Import quill
const {default: Quill} = await import("quill"); // eslint-disable-line @typescript-eslint/no-explicit-any
quill = new Quill(INPUT_CONSOLE_MESSAGE, {
theme: 'snow',
modules: {
toolbar: toolbarOptions
},
});
quill.on('selection-change', function (range, oldRange) {
if (range === null && oldRange !== null) {
consoleGlobalMessageManagerFocusStore.set(false);
} else if (range !== null && oldRange === null)
consoleGlobalMessageManagerFocusStore.set(true);
});
});
function disableConsole() {
consoleGlobalMessageManagerVisibleStore.set(false);
consoleGlobalMessageManagerFocusStore.set(false);
}
function SendTextMessage() {
if (gameScene == undefined) {
return;
}
const text = quill.getText(0, quill.getLength());
const GlobalMessage: PlayGlobalMessageInterface = {
id: "1", // FIXME: use another ID?
message: text,
type: MESSAGE_TYPE
};
quill.deleteText(0, quill.getLength());
gameScene.connection?.emitGlobalMessage(GlobalMessage);
disableConsole();
}
</script>
<section class="section-input-send-text">
<div class="input-send-text" bind:this={INPUT_CONSOLE_MESSAGE}></div>
<div class="btn-action">
<button class="nes-btn is-primary" on:click|preventDefault={SendTextMessage}>Send</button>
</div>
</section>
<style lang="scss">
@import 'https://cdn.quilljs.com/1.3.7/quill.snow.css';
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import {HtmlUtils} from "../../WebRtc/HtmlUtils";
import type {Game} from "../../Phaser/Game/Game";
import type {GameManager} from "../../Phaser/Game/GameManager";
import {consoleGlobalMessageManagerFocusStore, consoleGlobalMessageManagerVisibleStore} from "../../Stores/ConsoleGlobalMessageManagerStore";
import {AdminMessageEventTypes} from "../../Connexion/AdminMessagesService";
import type {PlayGlobalMessageInterface} from "../../Connexion/ConnexionModels";
import uploadFile from "../images/music-file.svg";
import {LoginSceneName} from "../../Phaser/Login/LoginScene";
interface EventTargetFiles extends EventTarget {
files: Array<File>;
}
export let game: Game;
export let gameManager: GameManager;
let gameScene = gameManager.getCurrentGameScene(game.scene.getScene(LoginSceneName));
let fileinput: HTMLInputElement;
let filename: string;
let filesize: string;
let errorfile: boolean;
const AUDIO_TYPE = AdminMessageEventTypes.audio;
async function SendAudioMessage() {
if (gameScene == undefined) {
return;
}
const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>("input-send-audio");
const selectedFile = inputAudio.files ? inputAudio.files[0] : null;
if (!selectedFile) {
errorfile = true;
throw 'no file selected';
}
const fd = new FormData();
fd.append('file', selectedFile);
const res = await gameScene.connection?.uploadAudio(fd);
const GlobalMessage: PlayGlobalMessageInterface = {
id: (res as { id: string }).id,
message: (res as { path: string }).path,
type: AUDIO_TYPE
}
inputAudio.value = '';
gameScene.connection?.emitGlobalMessage(GlobalMessage);
disableConsole();
}
function inputAudioFile(event: Event) {
const eventTarget : EventTargetFiles = (event.target as EventTargetFiles);
if(!eventTarget || !eventTarget.files || eventTarget.files.length === 0){
return;
}
const file = eventTarget.files[0];
if(!file) {
return;
}
filename = file.name;
filesize = getFileSize(file.size);
errorfile = false;
}
function getFileSize(number: number) {
if (number < 1024) {
return number + 'bytes';
} else if (number >= 1024 && number < 1048576) {
return (number / 1024).toFixed(1) + 'KB';
} else if (number >= 1048576) {
return (number / 1048576).toFixed(1) + 'MB';
} else {
return '';
}
}
function disableConsole() {
consoleGlobalMessageManagerVisibleStore.set(false);
consoleGlobalMessageManagerFocusStore.set(false);
}
</script>
<section class="section-input-send-audio">
<div class="input-send-audio">
<img src="{uploadFile}" alt="Upload a file" on:click|preventDefault={ () => {fileinput.click();}}>
{#if filename != undefined}
<label for="input-send-audio">{filename} : {filesize}</label>
{/if}
{#if errorfile}
<p class="err">No file selected. You need to upload a file before sending it.</p>
{/if}
<input type="file" id="input-send-audio" bind:this={fileinput} on:change={(e) => {inputAudioFile(e)}}>
</div>
<div class="btn-action">
<button class="nes-btn is-primary" on:click|preventDefault={SendAudioMessage}>Send</button>
</div>
</section>
<style lang="scss">
//UploadAudioGlobalMessage
.section-input-send-audio {
margin: 10px;
}
.section-input-send-audio .input-send-audio {
text-align: center;
}
.section-input-send-audio #input-send-audio{
display: none;
}
.section-input-send-audio div.input-send-audio label{
color: white;
}
.section-input-send-audio div.input-send-audio p.err {
color: #ce372b;
text-align: center;
}
.section-input-send-audio div.input-send-audio img{
height: 150px;
cursor: url('../../../style/images/cursor_pointer.png'), pointer;
}
</style>

View file

@ -1,10 +1,10 @@
<script lang="typescript">
import { Game } from "../../Phaser/Game/Game";
import { CustomizeSceneName } from "../../Phaser/Login/CustomizeScene";
import type { Game } from "../../Phaser/Game/Game";
import {CustomizeScene, CustomizeSceneName} from "../../Phaser/Login/CustomizeScene";
export let game: Game;
const customCharacterScene = game.scene.getScene(CustomizeSceneName);
const customCharacterScene = game.scene.getScene(CustomizeSceneName) as CustomizeScene;
let activeRow = customCharacterScene.activeRow;
function selectLeft() {
@ -116,4 +116,4 @@
}
</style>
</style>

View file

@ -1,5 +1,5 @@
<script lang="typescript">
import {Game} from "../../Phaser/Game/Game";
import type {Game} from "../../Phaser/Game/Game";
import {EnableCameraScene, EnableCameraSceneName} from "../../Phaser/Login/EnableCameraScene";
import {
audioConstraintStore,
@ -15,8 +15,8 @@
import microphoneImg from "../images/microphone.svg";
export let game: Game;
let selectedCamera : string|null = null;
let selectedMicrophone : string|null = null;
let selectedCamera : string|undefined = undefined;
let selectedMicrophone : string|undefined = undefined;
const enableCameraScene = game.scene.getScene(EnableCameraSceneName) as EnableCameraScene;
@ -24,10 +24,10 @@
enableCameraScene.login();
}
function srcObject(node, stream) {
function srcObject(node: HTMLVideoElement, stream: MediaStream) {
node.srcObject = stream;
return {
update(newStream) {
update(newStream: MediaStream) {
if (node.srcObject != newStream) {
node.srcObject = newStream
}
@ -53,16 +53,16 @@
}
} else {
stream = null;
selectedCamera = null;
selectedMicrophone = null;
selectedCamera = undefined;
selectedMicrophone = undefined;
}
});
onDestroy(unsubscribe);
function normalizeDeviceName(label: string): string {
// remove text in parenthesis
return label.replace(/\([^()]*\)/g, '').trim();
// remove IDs (that can appear in Chrome, like: "HD Pro Webcam (4df7:4eda)"
return label.replace(/(\([[0-9a-f]{4}:[0-9a-f]{4}\))/g, '').trim();
}
function selectCamera() {
@ -79,7 +79,7 @@
<section class="text-center">
<h2>Turn on your camera and microphone</h2>
</section>
{#if $localStreamStore.stream}
{#if $localStreamStore.type === 'success' && $localStreamStore.stream}
<video class="myCamVideoSetup" use:srcObject={$localStreamStore.stream} autoplay muted playsinline></video>
{:else }
<div class="webrtcsetup">
@ -93,7 +93,7 @@
{#if $cameraListStore.length > 1 }
<div class="control-group">
<img src={cinemaImg} alt="Camera" />
<div class="nes-select">
<div class="nes-select is-dark">
<select bind:value={selectedCamera} on:change={selectCamera}>
{#each $cameraListStore as camera}
<option value={camera.deviceId}>
@ -108,7 +108,7 @@
{#if $microphoneListStore.length > 1 }
<div class="control-group">
<img src={microphoneImg} alt="Microphone" />
<div class="nes-select">
<div class="nes-select is-dark">
<select bind:value={selectedMicrophone} on:change={selectMicrophone}>
{#each $microphoneListStore as microphone}
<option value={microphone.deviceId}>
@ -136,7 +136,7 @@
margin-top: 3vh;
margin-bottom: 3vh;
min-height: 10vh;
width: 50%;
width: 50vw;
margin-left: auto;
margin-right: auto;
@ -144,12 +144,12 @@
font-family: "Press Start 2P";
margin-top: 1vh;
margin-bottom: 1vh;
}
option {
option {
font-family: "Press Start 2P";
}
}
}
section.action{
text-align: center;
@ -173,6 +173,8 @@
.control-group {
display: flex;
flex-direction: row;
max-height: 60px;
margin-top: 10px;
img {
width: 30px;
@ -210,8 +212,18 @@
align-items: center;
justify-content: center;
}
}
@media only screen and (max-width: 800px) {
.enableCameraScene h2 {
font-size: 80%;
}
.enableCameraScene .control-group .nes-select {
font-size: 80%;
}
.enableCameraScene button.letsgo {
font-size: 160%;
}
}
</style>

View file

@ -8,7 +8,7 @@
const NB_BARS = 20;
let timeout;
let timeout: ReturnType<typeof setTimeout>;
const soundMeter = new SoundMeter();
let display = false;

View file

@ -1,14 +1,13 @@
<script lang="typescript">
import {Game} from "../../Phaser/Game/Game";
import {LoginSceneName} from "../../Phaser/Login/LoginScene";
import type {Game} from "../../Phaser/Game/Game";
import {LoginScene, LoginSceneName} from "../../Phaser/Login/LoginScene";
import {DISPLAY_TERMS_OF_USE, MAX_USERNAME_LENGTH} from "../../Enum/EnvironmentVariable";
import logoImg from "../images/logo.png";
import {gameManager} from "../../Phaser/Game/GameManager";
import {maxUserNameLength} from "../../Connexion/LocalUser";
export let game: Game;
const loginScene = game.scene.getScene(LoginSceneName);
const loginScene = game.scene.getScene(LoginSceneName) as LoginScene;
let name = gameManager.getPlayerName() || '';
let startValidating = false;

View file

@ -3,10 +3,10 @@
import SoundMeterWidget from "./SoundMeterWidget.svelte";
import {onDestroy} from "svelte";
function srcObject(node, stream) {
function srcObject(node: HTMLVideoElement, stream: MediaStream) {
node.srcObject = stream;
return {
update(newStream) {
update(newStream: MediaStream) {
if (node.srcObject != newStream) {
node.srcObject = newStream
}
@ -38,9 +38,9 @@
<div>
<div class="video-container div-myCamVideo" class:hide={!$localStreamStore.constraints.video}>
{#if $localStreamStore.type === "success" && $localStreamStore.stream }
<video class="myCamVideo" use:srcObject={$localStreamStore.stream} autoplay muted playsinline></video>
<!-- {#if stream}
<SoundMeterWidget stream={stream}></SoundMeterWidget>
{/if} -->
{/if}
</div>
</div>

View file

@ -1,10 +1,10 @@
<script lang="typescript">
import {Game} from "../../Phaser/Game/Game";
import {SelectCompanionSceneName} from "../../Phaser/Login/SelectCompanionScene";
import type {Game} from "../../Phaser/Game/Game";
import {SelectCompanionScene, SelectCompanionSceneName} from "../../Phaser/Login/SelectCompanionScene";
export let game: Game;
const selectCompanionScene = game.scene.getScene(SelectCompanionSceneName);
const selectCompanionScene = game.scene.getScene(SelectCompanionSceneName) as SelectCompanionScene;
function selectLeft() {
selectCompanionScene.moveToLeft();

View file

@ -8,7 +8,7 @@
const NB_BARS = 5;
let timeout;
let timeout: ReturnType<typeof setTimeout>;
const soundMeter = new SoundMeter();
let display = false;

View file

@ -2,7 +2,7 @@
import { fly } from 'svelte/transition';
import megaphoneImg from "./images/megaphone.svg";
import {soundPlayingStore} from "../../Stores/SoundPlayingStore";
import {afterUpdate, onMount} from "svelte";
import {afterUpdate} from "svelte";
export let url: string;
let audio: HTMLAudioElement;

View file

@ -0,0 +1,48 @@
<script lang="ts">
import {errorStore} from "../../Stores/ErrorStore";
function close(): boolean {
errorStore.clearMessages();
return false;
}
</script>
<div class="error-div nes-container is-dark is-rounded" open>
<p class="nes-text is-error title">Error</p>
<div class="body">
{#each $errorStore as error}
<p>{error}</p>
{/each}
</div>
<div class="button-bar">
<button class="nes-btn is-error" on:click={close}>Close</button>
</div>
</div>
<style lang="scss">
div.error-div {
pointer-events: auto;
margin-top: 10vh;
margin-right: auto;
margin-left: auto;
width: max-content;
max-width: 80vw;
.button-bar {
text-align: center;
}
.body {
max-height: 50vh;
}
p {
font-family: "Press Start 2P";
&.title {
text-align: center;
}
}
}
</style>

View file

@ -1,38 +1,64 @@
<script lang="typescript">
import { fly } from 'svelte/transition';
import {requestVisitCardsStore} from "../../Stores/GameStore";
import {onMount} from "svelte";
export let visitCardUrl: string;
let w = '500px';
let h = '250px';
let hidden = true;
let cvIframe: HTMLIFrameElement;
function closeCard() {
requestVisitCardsStore.set(null);
}
function handleIframeMessage(message:any) {
if (message.data.type === 'cvIframeSize') {
w = (message.data.data.w) + 'px';
h = (message.data.data.h) + 'px';
}
}
onMount(() => {
cvIframe.onload = () => hidden = false
cvIframe.onerror = () => hidden = false
})
</script>
<style lang="scss">
.loader {
border: 16px solid #f3f3f3; /* Light grey */
border-top: 16px solid #3498db; /* Blue */
border-radius: 50%;
width: 120px;
height: 120px;
margin:auto;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.visitCard {
pointer-events: all;
margin-left: auto;
margin-right: auto;
width: 515px;
margin-top: 200px;
.defaultCard {
border-radius: 5px;
border: 2px black solid;
background-color: whitesmoke;
width: 500px;
header {
padding: 5px;
}
}
max-width: 80vw;
iframe {
border: 0;
width: 515px;
height: 270px;
max-width: 80vw;
overflow: hidden;
&.hidden {
visibility: hidden;
position: absolute;
}
}
button {
@ -42,23 +68,17 @@
</style>
<section class="visitCard" transition:fly="{{ y: -200, duration: 1000 }}">
{#if visitCardUrl === 'INVALID'}
<div class="defaultCard">
<header>
<h2>Sorry</h2>
<p style="font-style: italic;">This user doesn't have a contact card.</p>
</header>
<main style="padding: 5px; background-color: gray">
<p>Maybe he is offline, or this feature is deactivated.</p>
</main>
</div>
{:else}
<iframe title="visitCardTitle" src={visitCardUrl}></iframe>
<section class="visitCard" transition:fly="{{ y: -200, duration: 1000 }}" style="width: {w}">
{#if hidden}
<div class="loader"></div>
{/if}
<div class="buttonContainer">
<button class="nes-btn is-popUpElement" on:click={closeCard}>Close</button>
</div>
<iframe title="visitCard" src={visitCardUrl} allow="clipboard-read; clipboard-write self {visitCardUrl}" style="width: {w}; height: {h}" class:hidden={hidden} bind:this={cvIframe}></iframe>
{#if !hidden}
<div class="buttonContainer">
<button class="nes-btn is-popUpElement" on:click={closeCard}>Close</button>
</div>
{/if}
</section>
<svelte:window on:message={handleIframeMessage}/>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 448 448" style="enable-background:new 0 0 448 448;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFDA01;}
</style>
<path class="st0" d="M348,288c-44.2,0-80,35.8-80,80s35.8,80,80,80s80-35.8,80-80C428,323.8,392.2,288,348,288z M387.6,359.6
c-3.1,3.1-8.2,3.1-11.3,0L356,339.3V416c0,4.4-3.6,8-8,8s-8-3.6-8-8v-76.7l-20.3,20.3c-3.1,3-8.1,3-11.2-0.1s-3.1-8.1-0.1-11.2
l33.9-33.9c0.7-0.7,1.6-1.3,2.6-1.7c2-0.8,4.2-0.8,6.1,0c1,0.4,1.9,1,2.6,1.7l33.9,33.9C390.7,351.4,390.7,356.5,387.6,359.6z"/>
<path class="st0" d="M244,154.6L148,182v15.4l96-27.4V154.6z"/>
<path class="st0" d="M244,280c0,8.8-7.2,16-16,16s-16-7.2-16-16s7.2-16,16-16S244,271.2,244,280z"/>
<path class="st0" d="M132,312c0,8.8-7.2,16-16,16s-16-7.2-16-16s7.2-16,16-16S132,303.2,132,312z"/>
<path class="st0" d="M31.3,80H100V11.3L31.3,80z"/>
<path class="st0" d="M20,448h275c-0.1-0.1-0.2-0.1-0.3-0.2c-2.9-2-5.8-4.1-8.5-6.4c-0.7-0.6-1.4-1.3-2.1-1.9
c-1.9-1.7-3.8-3.5-5.6-5.4c-0.8-0.9-1.6-1.8-2.4-2.7c-1.6-1.8-3.2-3.8-4.7-5.7c-0.7-0.9-1.4-1.8-2-2.7c-1.8-2.6-3.5-5.2-5-8
c-0.2-0.4-0.4-0.7-0.6-1c-1.7-3.1-3.2-6.4-4.6-9.7c-0.4-1-0.7-2-1.1-3c-0.9-2.4-1.7-4.9-2.4-7.4c-0.3-1.2-0.6-2.4-0.9-3.6
c-0.6-2.5-1.1-5-1.5-7.6c-0.2-1.1-0.4-2.3-0.5-3.4c-0.9-6.9-1-13.9-0.2-20.8c0.1-1.1,0.3-2.1,0.5-3.1c0.3-2.1,0.5-4.1,0.9-6.2
c0.2-1.2,0.6-2.4,0.9-3.7c0.4-1.8,0.8-3.6,1.4-5.3c0.4-1.3,0.9-2.5,1.3-3.8c0.6-1.6,1.1-3.3,1.8-4.9c0.5-1.3,1.1-2.5,1.7-3.7
c0.7-1.5,1.4-3,2.2-4.5c0.6-1.2,1.4-2.4,2-3.6c0.8-1.4,1.7-2.8,2.6-4.2c0.8-1.2,1.6-2.3,2.4-3.4c0.9-1.3,1.9-2.6,2.9-3.9
c0.9-1.1,1.8-2.1,2.7-3.2c1.1-1.2,2.1-2.4,3.2-3.6c1-1,2-2,3-2.9c1.2-1.1,2.3-2.2,3.6-3.2c1.1-0.9,2.1-1.8,3.2-2.7
c1.3-1,2.6-2,3.9-2.9c1.1-0.8,2.3-1.6,3.5-2.4c1.4-0.9,2.8-1.7,4.2-2.5c1.2-0.7,2.4-1.4,3.6-2c1.5-0.8,2.9-1.5,4.4-2.1
c1.3-0.6,2.5-1.2,3.8-1.7c1.6-0.6,3.1-1.2,4.7-1.7c1.3-0.4,2.6-0.9,3.9-1.3c1.6-0.5,3.3-0.9,5-1.3c1.3-0.3,2.6-0.7,4-0.9
c1.8-0.3,3.5-0.6,5.3-0.8c1.3-0.2,2.6-0.4,4-0.5c0.3,0,0.6-0.1,1-0.1V0H116v88c0,4.4-3.6,8-8,8H20V448z M116,280
c5.6,0,11.2,1.6,16,4.4V176c0-3.6,2.4-6.7,5.8-7.7l112-32c2.4-0.7,5-0.2,7,1.3s3.2,3.9,3.2,6.4v136c0,17.7-14.3,32-32,32
s-32-14.3-32-32s14.3-32,32-32c5.6,0,11.2,1.6,16,4.4v-65.8L148,214v98c0,17.7-14.3,32-32,32s-32-14.3-32-32S98.3,280,116,280z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1,10 +1,10 @@
<script lang="typescript">
import { Game } from "../../Phaser/Game/Game";
import { SelectCharacterSceneName } from "../../Phaser/Login/SelectCharacterScene";
import type { Game } from "../../Phaser/Game/Game";
import {SelectCharacterScene, SelectCharacterSceneName} from "../../Phaser/Login/SelectCharacterScene";
export let game: Game;
const selectCharacterScene = game.scene.getScene(SelectCharacterSceneName);
const selectCharacterScene = game.scene.getScene(SelectCharacterSceneName) as SelectCharacterScene;
function selectLeft() {
selectCharacterScene.moveToLeft();
@ -89,4 +89,4 @@
}
</style>
</style>

View file

@ -1,5 +1,3 @@
import {PlayerAnimationDirections} from "../Phaser/Player/Animation";
import {UserSimplePeerInterface} from "../WebRtc/SimplePeer";
import type {SignalData} from "simple-peer";
import type {RoomConnection} from "./RoomConnection";
import type {BodyResourceDescriptionInterface} from "../Phaser/Entity/PlayerTextures";
@ -47,6 +45,7 @@ export interface MessageUserPositionInterface {
name: string;
characterLayers: BodyResourceDescriptionInterface[];
position: PointInterface;
visitCardUrl: string|null;
companion: string|null;
}
@ -60,6 +59,7 @@ export interface MessageUserJoined {
name: string;
characterLayers: BodyResourceDescriptionInterface[];
position: PointInterface;
visitCardUrl: string | null;
companion: string|null;
}
@ -85,11 +85,6 @@ export interface WebRtcSignalReceivedMessageInterface {
webRtcPassword: string | undefined
}
export interface StartMapInterface {
mapUrlStart: string,
startInstance: string
}
export interface ViewportInterface {
left: number,
top: number,

View file

@ -10,7 +10,7 @@ export interface CharacterTexture {
export const maxUserNameLength: number = MAX_USERNAME_LENGTH;
export function isUserNameValid(value: unknown): boolean {
return typeof value === "string" && value.length > 0 && value.length <= maxUserNameLength && value.indexOf(' ') === -1;
return typeof value === "string" && value.length > 0 && value.length <= maxUserNameLength && /\S/.test(value);
}
export function areCharacterLayersValid(value: string[] | null): boolean {

View file

@ -1,4 +1,4 @@
import {PUSHER_URL, UPLOADER_URL} from "../Enum/EnvironmentVariable";
import { PUSHER_URL, UPLOADER_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios";
import {
BatchMessage,
@ -30,12 +30,12 @@ import {
EmoteEventMessage,
EmotePromptMessage,
SendUserMessage,
BanUserMessage, RequestVisitCardMessage
BanUserMessage,
} from "../Messages/generated/messages_pb"
import type {UserSimplePeerInterface} from "../WebRtc/SimplePeer";
import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer";
import Direction = PositionMessage.Direction;
import {ProtobufClientUtils} from "../Network/ProtobufClientUtils";
import { ProtobufClientUtils } from "../Network/ProtobufClientUtils";
import {
EventMessage,
GroupCreatedUpdatedMessageInterface, ItemEventMessageInterface,
@ -44,25 +44,24 @@ import {
ViewportInterface, WebRtcDisconnectMessageInterface,
WebRtcSignalReceivedMessageInterface,
} from "./ConnexionModels";
import type {BodyResourceDescriptionInterface} from "../Phaser/Entity/PlayerTextures";
import {adminMessagesService} from "./AdminMessagesService";
import {worldFullMessageStream} from "./WorldFullMessageStream";
import {worldFullWarningStream} from "./WorldFullWarningStream";
import {connectionManager} from "./ConnectionManager";
import {emoteEventStream} from "./EmoteEventStream";
import {requestVisitCardsStore} from "../Stores/GameStore";
import type { BodyResourceDescriptionInterface } from "../Phaser/Entity/PlayerTextures";
import { adminMessagesService } from "./AdminMessagesService";
import { worldFullMessageStream } from "./WorldFullMessageStream";
import { worldFullWarningStream } from "./WorldFullWarningStream";
import { connectionManager } from "./ConnectionManager";
import { emoteEventStream } from "./EmoteEventStream";
const manualPingDelay = 20000;
export class RoomConnection implements RoomConnection {
private readonly socket: WebSocket;
private userId: number|null = null;
private userId: number | null = null;
private listeners: Map<string, Function[]> = new Map<string, Function[]>();
private static websocketFactory: null|((url: string)=>any) = null; // eslint-disable-line @typescript-eslint/no-explicit-any
private static websocketFactory: null | ((url: string) => any) = null; // eslint-disable-line @typescript-eslint/no-explicit-any
private closed: boolean = false;
private tags: string[] = [];
public static setWebsocketFactory(websocketFactory: (url: string)=>any): void { // eslint-disable-line @typescript-eslint/no-explicit-any
public static setWebsocketFactory(websocketFactory: (url: string) => any): void { // eslint-disable-line @typescript-eslint/no-explicit-any
RoomConnection.websocketFactory = websocketFactory;
}
@ -71,28 +70,27 @@ export class RoomConnection implements RoomConnection {
* @param token A JWT token containing the UUID of the user
* @param roomId The ID of the room in the form "_/[instance]/[map_url]" or "@/[org]/[event]/[map]"
*/
public constructor(token: string|null, roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null) {
public constructor(token: string | null, roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string | null) {
let url = new URL(PUSHER_URL, window.location.toString()).toString();
url = url.replace('http://', 'ws://').replace('https://', 'wss://');
if (!url.endsWith('/')) {
url += '/';
}
url += 'room';
url += '?roomId='+(roomId ?encodeURIComponent(roomId):'');
url += '&token='+(token ?encodeURIComponent(token):'');
url += '&name='+encodeURIComponent(name);
url += '?roomId=' + (roomId ? encodeURIComponent(roomId) : '');
url += '&token=' + (token ? encodeURIComponent(token) : '');
url += '&name=' + encodeURIComponent(name);
for (const layer of characterLayers) {
url += '&characterLayers='+encodeURIComponent(layer);
url += '&characterLayers=' + encodeURIComponent(layer);
}
url += '&x='+Math.floor(position.x);
url += '&y='+Math.floor(position.y);
url += '&top='+Math.floor(viewport.top);
url += '&bottom='+Math.floor(viewport.bottom);
url += '&left='+Math.floor(viewport.left);
url += '&right='+Math.floor(viewport.right);
url += '&x=' + Math.floor(position.x);
url += '&y=' + Math.floor(position.y);
url += '&top=' + Math.floor(viewport.top);
url += '&bottom=' + Math.floor(viewport.bottom);
url += '&left=' + Math.floor(viewport.left);
url += '&right=' + Math.floor(viewport.right);
if (typeof companion === 'string') {
url += '&companion='+encodeURIComponent(companion);
url += '&companion=' + encodeURIComponent(companion);
}
if (RoomConnection.websocketFactory) {
@ -103,7 +101,7 @@ export class RoomConnection implements RoomConnection {
this.socket.binaryType = 'arraybuffer';
let interval: ReturnType<typeof setInterval>|undefined = undefined;
let interval: ReturnType<typeof setInterval> | undefined = undefined;
this.socket.onopen = (ev) => {
//we manually ping every 20s to not be logged out by the server, even when the game is in background.
@ -162,7 +160,7 @@ export class RoomConnection implements RoomConnection {
} else if (message.hasRoomjoinedmessage()) {
const roomJoinedMessage = message.getRoomjoinedmessage() as RoomJoinedMessage;
const items: { [itemId: number] : unknown } = {};
const items: { [itemId: number]: unknown } = {};
for (const item of roomJoinedMessage.getItemList()) {
items[item.getItemid()] = JSON.parse(item.getStatejson());
}
@ -182,7 +180,7 @@ export class RoomConnection implements RoomConnection {
} else if (message.hasWorldconnexionmessage()) {
worldFullMessageStream.onMessage(message.getWorldconnexionmessage()?.getMessage());
this.closed = true;
}else if (message.hasWebrtcsignaltoclientmessage()) {
} else if (message.hasWebrtcsignaltoclientmessage()) {
this.dispatch(EventMessage.WEBRTC_SIGNAL, message.getWebrtcsignaltoclientmessage());
} else if (message.hasWebrtcscreensharingsignaltoclientmessage()) {
this.dispatch(EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, message.getWebrtcscreensharingsignaltoclientmessage());
@ -204,8 +202,6 @@ export class RoomConnection implements RoomConnection {
adminMessagesService.onSendusermessage(message.getBanusermessage() as BanUserMessage);
} else if (message.hasWorldfullwarningmessage()) {
worldFullWarningStream.onMessage();
} else if (message.hasVisitcardmessage()) {
requestVisitCardsStore.set(message?.getVisitcardmessage()?.getUrl() as unknown as string);
} else if (message.hasRefreshroommessage()) {
//todo: implement a way to notify the user the room was refreshed.
} else {
@ -241,7 +237,7 @@ export class RoomConnection implements RoomConnection {
this.closed = true;
}
private toPositionMessage(x : number, y : number, direction : string, moving: boolean): PositionMessage {
private toPositionMessage(x: number, y: number, direction: string, moving: boolean): PositionMessage {
const positionMessage = new PositionMessage();
positionMessage.setX(Math.floor(x));
positionMessage.setY(Math.floor(y));
@ -278,8 +274,8 @@ export class RoomConnection implements RoomConnection {
return viewportMessage;
}
public sharePosition(x : number, y : number, direction : string, moving: boolean, viewport: ViewportInterface) : void{
if(!this.socket){
public sharePosition(x: number, y: number, direction: string, moving: boolean, viewport: ViewportInterface): void {
if (!this.socket) {
return;
}
@ -347,6 +343,7 @@ export class RoomConnection implements RoomConnection {
userId: message.getUserid(),
name: message.getName(),
characterLayers,
visitCardUrl: message.getVisitcardurl(),
position: ProtobufClientUtils.toPointInterface(position),
companion: companion ? companion.getName() : null
}
@ -483,7 +480,7 @@ export class RoomConnection implements RoomConnection {
if (this.closed === true || connectionManager.unloading) {
return;
}
console.log('Socket closed with code '+event.code+". Reason: "+event.reason);
console.log('Socket closed with code ' + event.code + ". Reason: " + event.reason);
if (event.code === 1000) {
// Normal closure case
return;
@ -529,8 +526,8 @@ export class RoomConnection implements RoomConnection {
});
}
public uploadAudio(file : FormData){
return Axios.post(`${UPLOADER_URL}/upload-audio-message`, file).then((res: {data:{}}) => {
public uploadAudio(file: FormData) {
return Axios.post(`${UPLOADER_URL}/upload-audio-message`, file).then((res: { data: {} }) => {
return res.data;
}).catch((err) => {
console.error(err);
@ -561,7 +558,7 @@ export class RoomConnection implements RoomConnection {
});
}
public emitGlobalMessage(message: PlayGlobalMessageInterface){
public emitGlobalMessage(message: PlayGlobalMessageInterface) {
const playGlobalMessage = new PlayGlobalMessage();
playGlobalMessage.setId(message.id);
playGlobalMessage.setType(message.type);
@ -573,7 +570,7 @@ export class RoomConnection implements RoomConnection {
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public emitReportPlayerMessage(reportedUserId: number, reportComment: string ): void {
public emitReportPlayerMessage(reportedUserId: number, reportComment: string): void {
const reportPlayerMessage = new ReportPlayerMessage();
reportPlayerMessage.setReporteduserid(reportedUserId);
reportPlayerMessage.setReportcomment(reportComment);
@ -584,7 +581,7 @@ export class RoomConnection implements RoomConnection {
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public emitQueryJitsiJwtMessage(jitsiRoom: string, tag: string|undefined ): void {
public emitQueryJitsiJwtMessage(jitsiRoom: string, tag: string | undefined): void {
const queryJitsiJwtMessage = new QueryJitsiJwtMessage();
queryJitsiJwtMessage.setJitsiroom(jitsiRoom);
if (tag !== undefined) {
@ -620,14 +617,8 @@ export class RoomConnection implements RoomConnection {
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public requestVisitCardUrl(targetUserId: number): void {
const message = new RequestVisitCardMessage();
message.setTargetuserid(targetUserId);
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setRequestvisitcardmessage(message);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
public getAllTags() : string[] {
return this.tags;
}
}

View file

@ -8,6 +8,7 @@ import {Companion} from "../Companion/Companion";
import type {GameScene} from "../Game/GameScene";
import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes";
import {waScaleManager} from "../Services/WaScaleManager";
import type OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js";
const playerNameY = - 25;
@ -32,6 +33,7 @@ export abstract class Character extends Container {
public companion?: Companion;
private emote: Phaser.GameObjects.Sprite | null = null;
private emoteTween: Phaser.Tweens.Tween|null = null;
scene: GameScene;
constructor(scene: GameScene,
x: number,
@ -41,10 +43,12 @@ export abstract class Character extends Container {
direction: PlayerAnimationDirections,
moving: boolean,
frame: string | number,
isClickable: boolean,
companion: string|null,
companionTexturePromise?: Promise<string>
) {
super(scene, x, y/*, texture, frame*/);
this.scene = scene;
this.PlayerValue = name;
this.invisible = true
@ -55,17 +59,30 @@ export abstract class Character extends Container {
this.addTextures(textures, frame);
this.invisible = false
})
this.playerName = new Text(scene, 0, playerNameY, name, {fontFamily: '"Press Start 2P"', fontSize: '8px', strokeThickness: 2, stroke: "gray"});
this.playerName.setOrigin(0.5).setDepth(DEPTH_INGAME_TEXT_INDEX);
this.add(this.playerName);
if (this.isClickable()) {
if (isClickable) {
this.setInteractive({
hitArea: new Phaser.Geom.Circle(0, 0, interactiveRadius),
hitAreaCallback: Phaser.Geom.Circle.Contains, //eslint-disable-line @typescript-eslint/unbound-method
useHandCursor: true,
});
this.on('pointerover',() => {
this.getOutlinePlugin()?.add(this.playerName, {
thickness: 2,
outlineColor: 0xffff00
});
this.scene.markDirty();
});
this.on('pointerout',() => {
this.getOutlinePlugin()?.remove(this.playerName);
this.scene.markDirty();
})
}
scene.add.existing(this);
@ -79,23 +96,25 @@ export abstract class Character extends Container {
this.setDepth(-1);
this.playAnimation(direction, moving);
if (typeof companion === 'string') {
this.addCompanion(companion, companionTexturePromise);
}
}
private getOutlinePlugin(): OutlinePipelinePlugin|undefined {
return this.scene.plugins.get('rexOutlinePipeline') as unknown as OutlinePipelinePlugin|undefined;
}
public addCompanion(name: string, texturePromise?: Promise<string>): void {
if (typeof texturePromise !== 'undefined') {
this.companion = new Companion(this.scene, this.x, this.y, name, texturePromise);
}
}
public abstract isClickable(): boolean;
public addTextures(textures: string[], frame?: string | number): void {
for (const texture of textures) {
if(!this.scene.textures.exists(texture)){
if(this.scene && !this.scene.textures.exists(texture)){
throw new TextureError('texture not found');
}
const sprite = new Sprite(this.scene, 0, 0, texture, frame);
@ -240,28 +259,28 @@ export abstract class Character extends Container {
this.scene.sys.updateList.remove(sprite);
}
}
this.list.forEach(objectContaining => objectContaining.destroy())
this.list.forEach(objectContaining => objectContaining.destroy())
super.destroy();
}
playEmote(emoteKey: string) {
this.cancelPreviousEmote();
const scalingFactor = waScaleManager.uiScalingFactor * 0.05;
const emoteY = -30 - scalingFactor * 10;
this.playerName.setVisible(false);
this.emote = new Sprite(this.scene, 0, 0, emoteKey);
this.emote.setAlpha(0);
this.emote.setScale(0.1 * scalingFactor);
this.add(this.emote);
this.scene.sys.updateList.add(this.emote);
this.createStartTransition(scalingFactor, emoteY);
}
private createStartTransition(scalingFactor: number, emoteY: number) {
this.emoteTween = this.scene.tweens.add({
this.emoteTween = this.scene?.tweens.add({
targets: this.emote,
props: {
scale: scalingFactor,
@ -277,7 +296,7 @@ export abstract class Character extends Container {
}
private startPulseTransition(emoteY: number, scalingFactor: number) {
this.emoteTween = this.scene.tweens.add({
this.emoteTween = this.scene?.tweens.add({
targets: this.emote,
props: {
y: emoteY * 1.3,
@ -294,7 +313,7 @@ export abstract class Character extends Container {
}
private startExitTransition(emoteY: number) {
this.emoteTween = this.scene.tweens.add({
this.emoteTween = this.scene?.tweens.add({
targets: this.emote,
props: {
alpha: 0,

View file

@ -0,0 +1,20 @@
import Container = Phaser.GameObjects.Container;
import type {Scene} from "phaser";
import Sprite = Phaser.GameObjects.Sprite;
/**
* A sprite of a customized character (used in the Customize Scene only)
*/
export class CustomizedCharacter extends Container {
public constructor(scene: Scene, x: number, y: number, layers: string[]) {
super(scene, x, y);
this.updateSprites(layers);
}
public updateSprites(layers: string[]): void {
this.removeAll(true);
for (const layer of layers) {
this.add(new Sprite(this.scene, 0, 0, layer));
}
}
}

View file

@ -2,14 +2,15 @@ import type {GameScene} from "../Game/GameScene";
import type {PointInterface} from "../../Connexion/ConnexionModels";
import {Character} from "../Entity/Character";
import type {PlayerAnimationDirections} from "../Player/Animation";
import {requestVisitCardsStore} from "../../Stores/GameStore";
export const playerClickedEvent = 'playerClickedEvent';
/**
* Class representing the sprite of a remote player (a player that plays on another computer)
*/
export class RemotePlayer extends Character {
userId: number;
private visitCardUrl: string|null;
constructor(
userId: number,
@ -20,16 +21,18 @@ export class RemotePlayer extends Character {
texturesPromise: Promise<string[]>,
direction: PlayerAnimationDirections,
moving: boolean,
visitCardUrl: string|null,
companion: string|null,
companionTexturePromise?: Promise<string>
) {
super(Scene, x, y, texturesPromise, name, direction, moving, 1, companion, companionTexturePromise);
super(Scene, x, y, texturesPromise, name, direction, moving, 1, !!visitCardUrl, companion, companionTexturePromise);
//set data
this.userId = userId;
this.visitCardUrl = visitCardUrl;
this.on('pointerdown', () => {
this.emit(playerClickedEvent, this.userId);
requestVisitCardsStore.set(this.visitCardUrl);
})
}
@ -44,8 +47,4 @@ export class RemotePlayer extends Character {
this.companion.setTarget(position.x, position.y, position.direction as PlayerAnimationDirections);
}
}
isClickable(): boolean {
return true; //todo: make remote players clickable if they are logged in.
}
}

View file

@ -6,5 +6,6 @@ export interface AddPlayerInterface {
name: string;
characterLayers: BodyResourceDescriptionInterface[];
position: PointInterface;
visitCardUrl: string|null;
companion: string|null;
}

View file

@ -37,6 +37,7 @@ export abstract class DirtyScene extends ResizableScene {
this.events.on(Events.RENDER, () => {
this.objectListChanged = false;
this.dirty = false;
});
this.physics.disableUpdate();
@ -69,6 +70,10 @@ export abstract class DirtyScene extends ResizableScene {
return this.dirty || this.objectListChanged;
}
public markDirty(): void {
this.events.once(Phaser.Scenes.Events.POST_UPDATE, () => this.dirty = true);
}
public onResize(): void {
this.objectListChanged = true;
}

View file

@ -10,12 +10,7 @@ import {get} from "svelte/store";
import {requestedCameraState, requestedMicrophoneState} from "../../Stores/MediaStore";
import {helpCameraSettingsVisibleStore} from "../../Stores/HelpCameraSettingsStore";
export interface HasMovedEvent {
direction: string;
moving: boolean;
x: number;
y: number;
}
/**
* This class should be responsible for any scene starting/stopping

View file

@ -1,5 +1,7 @@
import type {ITiledMap, ITiledMapLayer} from "../Map/ITiledMap";
import {LayersIterator} from "../Map/LayersIterator";
import type {ITiledMap, ITiledMapLayer, ITiledMapLayerProperty} from "../Map/ITiledMap";
import { flattenGroupLayersMap } from "../Map/LayersFlattener";
import TilemapLayer = Phaser.Tilemaps.TilemapLayer;
import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes";
export type PropertyChangeCallback = (newValue: string | number | boolean | undefined, oldValue: string | number | boolean | undefined, allProps: Map<string, string | boolean | number>) => void;
@ -8,15 +10,43 @@ export type PropertyChangeCallback = (newValue: string | number | boolean | unde
* It is used to handle layer properties.
*/
export class GameMap {
private key: number|undefined;
private lastProperties = new Map<string, string|boolean|number>();
private key: number | undefined;
private lastProperties = new Map<string, string | boolean | number>();
private callbacks = new Map<string, Array<PropertyChangeCallback>>();
public readonly layersIterator: LayersIterator;
public constructor(private map: ITiledMap) {
this.layersIterator = new LayersIterator(map);
private tileSetPropertyMap: { [tile_index: number]: Array<ITiledMapLayerProperty> } = {}
public readonly flatLayers: ITiledMapLayer[];
public readonly phaserLayers: TilemapLayer[] = [];
public exitUrls: Array<string> = []
public constructor(private map: ITiledMap, phaserMap: Phaser.Tilemaps.Tilemap, terrains: Array<Phaser.Tilemaps.Tileset>) {
this.flatLayers = flattenGroupLayersMap(map);
let depth = -2;
for (const layer of this.flatLayers) {
if(layer.type === 'tilelayer'){
this.phaserLayers.push(phaserMap.createLayer(layer.name, terrains, 0, 0).setDepth(depth));
}
if (layer.type === 'objectgroup' && layer.name === 'floorLayer') {
depth = DEPTH_OVERLAY_INDEX;
}
}
for (const tileset of map.tilesets) {
tileset?.tiles?.forEach(tile => {
if (tile.properties) {
this.tileSetPropertyMap[tileset.firstgid + tile.id] = tile.properties
tile.properties.forEach(prop => {
if (prop.name == "exitUrl" && typeof prop.value == "string") {
this.exitUrls.push(prop.value);
}
})
}
})
}
}
/**
* Sets the position of the current player (in pixels)
* This will trigger events if properties are changing.
@ -51,21 +81,27 @@ export class GameMap {
}
}
public getCurrentProperties(): Map<string, string|boolean|number> {
public getCurrentProperties(): Map<string, string | boolean | number> {
return this.lastProperties;
}
private getProperties(key: number): Map<string, string|boolean|number> {
const properties = new Map<string, string|boolean|number>();
private getProperties(key: number): Map<string, string | boolean | number> {
const properties = new Map<string, string | boolean | number>();
for (const layer of this.layersIterator) {
for (const layer of this.flatLayers) {
if (layer.type !== 'tilelayer') {
continue;
}
const tiles = layer.data as number[];
if (tiles[key] == 0) {
continue;
let tileIndex: number | undefined = undefined;
if (layer.data) {
const tiles = layer.data as number[];
if (tiles[key] == 0) {
continue;
}
tileIndex = tiles[key]
}
// There is a tile in this layer, let's embed the properties
if (layer.properties !== undefined) {
for (const layerProperty of layer.properties) {
@ -75,11 +111,25 @@ export class GameMap {
properties.set(layerProperty.name, layerProperty.value);
}
}
if (tileIndex) {
this.tileSetPropertyMap[tileIndex]?.forEach(property => {
if (property.value) {
properties.set(property.name, property.value)
} else if (properties.has(property.name)) {
properties.delete(property.name)
}
})
}
}
return properties;
}
public getMap(): ITiledMap{
return this.map;
}
private trigger(propName: string, oldValue: string | number | boolean | undefined, newValue: string | number | boolean | undefined, allProps: Map<string, string | boolean | number>) {
const callbacksArray = this.callbacks.get(propName);
if (callbacksArray !== undefined) {
@ -100,4 +150,19 @@ export class GameMap {
}
callbacksArray.push(callback);
}
public findLayer(layerName: string): ITiledMapLayer | undefined {
return this.flatLayers.find((layer) => layer.name === layerName);
}
public findPhaserLayer(layerName: string): TilemapLayer | undefined {
return this.phaserLayers.find((layer) => layer.layer.name === layerName);
}
public addTerrain(terrain : Phaser.Tilemaps.Tileset): void {
for (const phaserLayer of this.phaserLayers) {
phaserLayer.tileset.push(terrain);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
import type {HasMovedEvent} from "./GameManager";
import {MAX_EXTRAPOLATION_TIME} from "../../Enum/EnvironmentVariable";
import type {PositionInterface} from "../../Connexion/ConnexionModels";
import { MAX_EXTRAPOLATION_TIME } from "../../Enum/EnvironmentVariable";
import type { PositionInterface } from "../../Connexion/ConnexionModels";
import type { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent';
export class PlayerMovement {
public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasMovedEvent, private endTick: number) {
public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasPlayerMovedEvent, private endTick: number) {
}
public isOutdated(tick: number): boolean {
@ -17,7 +17,7 @@ export class PlayerMovement {
return tick > this.endTick + MAX_EXTRAPOLATION_TIME;
}
public getPosition(tick: number): HasMovedEvent {
public getPosition(tick: number): HasPlayerMovedEvent {
// Special case: end position reached and end position is not moving
if (tick >= this.endTick && this.endPosition.moving === false) {
//console.log('Movement finished ', this.endPosition)

View file

@ -2,13 +2,13 @@
* This class is in charge of computing the position of all players.
* Player movement is delayed by 200ms so position depends on ticks.
*/
import type {PlayerMovement} from "./PlayerMovement";
import type {HasMovedEvent} from "./GameManager";
import type { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent';
import type { PlayerMovement } from "./PlayerMovement";
export class PlayersPositionInterpolator {
playerMovements: Map<number, PlayerMovement> = new Map<number, PlayerMovement>();
updatePlayerPosition(userId: number, playerMovement: PlayerMovement) : void {
updatePlayerPosition(userId: number, playerMovement: PlayerMovement): void {
this.playerMovements.set(userId, playerMovement);
}
@ -16,8 +16,8 @@ export class PlayersPositionInterpolator {
this.playerMovements.delete(userId);
}
getUpdatedPositions(tick: number) : Map<number, HasMovedEvent> {
const positions = new Map<number, HasMovedEvent>();
getUpdatedPositions(tick: number): Map<number, HasPlayerMovedEvent> {
const positions = new Map<number, HasPlayerMovedEvent>();
this.playerMovements.forEach((playerMovement: PlayerMovement, userId: number) => {
if (playerMovement.isOutdated(tick)) {
//console.log("outdated")

View file

@ -3,8 +3,8 @@
* It has coordinates and an "activation radius"
*/
import Sprite = Phaser.GameObjects.Sprite;
import {OutlinePipeline} from "../Shaders/OutlinePipeline";
import type {GameScene} from "../Game/GameScene";
import type OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js";
type EventCallback = (state: unknown, parameters: unknown) => void;
@ -42,11 +42,11 @@ export class ActionableItem {
return;
}
this.isSelectable = true;
if (this.sprite.pipeline) {
// Commented out to try to fix MacOS issue
/*this.sprite.setPipeline(OutlinePipeline.KEY);
this.sprite.pipeline.set2f('uTextureSize', this.sprite.texture.getSourceImage().width, this.sprite.texture.getSourceImage().height);*/
}
this.getOutlinePlugin()?.add(this.sprite, {
thickness: 2,
outlineColor: 0xffff00
});
}
/**
@ -57,8 +57,11 @@ export class ActionableItem {
return;
}
this.isSelectable = false;
// Commented out to try to fix MacOS issue
//this.sprite.resetPipeline();
this.getOutlinePlugin()?.remove(this.sprite);
}
private getOutlinePlugin(): OutlinePipelinePlugin|undefined {
return this.sprite.scene.plugins.get('rexOutlinePipeline') as unknown as OutlinePipelinePlugin|undefined;
}
/**

View file

@ -2,35 +2,33 @@ import {EnableCameraSceneName} from "./EnableCameraScene";
import Rectangle = Phaser.GameObjects.Rectangle;
import {loadAllLayers} from "../Entity/PlayerTexturesLoadingManager";
import Sprite = Phaser.GameObjects.Sprite;
import Container = Phaser.GameObjects.Container;
import {gameManager} from "../Game/GameManager";
import {localUserStore} from "../../Connexion/LocalUserStore";
import {addLoader} from "../Components/Loader";
import type {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures";
import {AbstractCharacterScene} from "./AbstractCharacterScene";
import {areCharacterLayersValid} from "../../Connexion/LocalUser";
import { MenuScene } from "../Menu/MenuScene";
import { SelectCharacterSceneName } from "./SelectCharacterScene";
import {customCharacterSceneVisibleStore} from "../../Stores/CustomCharacterStore";
import {selectCharacterSceneVisibleStore} from "../../Stores/SelectCharacterStore";
import {waScaleManager} from "../Services/WaScaleManager";
import {isMobile} from "../../Enum/EnvironmentVariable";
import {CustomizedCharacter} from "../Entity/CustomizedCharacter";
export const CustomizeSceneName = "CustomizeScene";
export const CustomizeSceneKey = "CustomizeScene";
const customizeSceneKey = 'customizeScene';
export class CustomizeScene extends AbstractCharacterScene {
private Rectangle!: Rectangle;
private selectedLayers: number[] = [0];
private containersRow: Container[][] = [];
private containersRow: CustomizedCharacter[][] = [];
public activeRow:number = 0;
private layers: BodyResourceDescriptionInterface[][] = [];
protected lazyloadingAttempt = true; //permit to update texture loaded after renderer
private moveHorizontally: number = 0;
private moveVertically: number = 0;
constructor() {
super({
key: CustomizeSceneName
@ -38,7 +36,6 @@ export class CustomizeScene extends AbstractCharacterScene {
}
preload() {
this.load.html(customizeSceneKey, 'resources/html/CustomCharacterScene.html');
this.loadCustomSceneSelectCharacters().then((bodyResourceDescriptions) => {
bodyResourceDescriptions.forEach((bodyResourceDescription) => {
@ -88,10 +85,13 @@ export class CustomizeScene extends AbstractCharacterScene {
this.backToPreviousScene();
});
this.input.keyboard.on('keyup-RIGHT', () => this.moveCursorHorizontally(1));
this.input.keyboard.on('keyup-LEFT', () => this.moveCursorHorizontally(-1));
this.input.keyboard.on('keyup-DOWN', () => this.moveCursorVertically(1));
this.input.keyboard.on('keyup-UP', () => this.moveCursorVertically(-1));
// Note: the key bindings are not directly put on the moveCursorVertically or moveCursorHorizontally methods
// because if 2 such events are fired close to one another, it makes the whole application crawl to a halt (for a reason I cannot
// explain, the list of sprites managed by the update list become immense
this.input.keyboard.on('keyup-RIGHT', () => this.moveHorizontally = 1);
this.input.keyboard.on('keyup-LEFT', () => this.moveHorizontally = -1);
this.input.keyboard.on('keyup-DOWN', () => this.moveVertically = 1);
this.input.keyboard.on('keyup-UP', () => this.moveVertically = -1);
const customCursorPosition = localUserStore.getCustomCursorPosition();
if (customCursorPosition) {
@ -105,6 +105,14 @@ export class CustomizeScene extends AbstractCharacterScene {
}
public moveCursorHorizontally(index: number): void {
this.moveHorizontally = index;
}
public moveCursorVertically(index: number): void {
this.moveVertically = index;
}
private doMoveCursorHorizontally(index: number): void {
this.selectedLayers[this.activeRow] += index;
if (this.selectedLayers[this.activeRow] < 0) {
this.selectedLayers[this.activeRow] = 0
@ -116,7 +124,7 @@ export class CustomizeScene extends AbstractCharacterScene {
this.saveInLocalStorage();
}
public moveCursorVertically(index:number): void {
private doMoveCursorVertically(index:number): void {
this.activeRow += index;
if (this.activeRow < 0) {
@ -165,20 +173,20 @@ export class CustomizeScene extends AbstractCharacterScene {
* @param selectedItem, The number of the item select (0 for black body...)
*/
private generateCharacter(x: number, y: number, layerNumber: number, selectedItem: number) {
return new Container(this, x, y,this.getContainerChildren(layerNumber,selectedItem));
return new CustomizedCharacter(this, x, y, this.getContainerChildren(layerNumber,selectedItem));
}
private getContainerChildren(layerNumber: number, selectedItem: number): Array<Sprite> {
const children: Array<Sprite> = new Array<Sprite>();
private getContainerChildren(layerNumber: number, selectedItem: number): Array<string> {
const children: Array<string> = new Array<string>();
for (let j = 0; j <= layerNumber; j++) {
if (j === layerNumber) {
children.push(this.generateLayers(0, 0, this.layers[j][selectedItem].name));
children.push(this.layers[j][selectedItem].name);
} else {
const layer = this.selectedLayers[j];
if (layer === undefined) {
continue;
}
children.push(this.generateLayers(0, 0, this.layers[j][layer].name));
children.push(this.layers[j][layer].name);
}
}
return children;
@ -215,15 +223,15 @@ export class CustomizeScene extends AbstractCharacterScene {
* @return a new sprite
*/
private generateLayers(x: number, y: number, name: string): Sprite {
return new Sprite(this, x, y, name);
//return new Sprite(this, x, y, name);
return this.add.sprite(0, 0, name);
}
private updateSelectedLayer() {
for(let i = 0; i < this.containersRow.length; i++){
for(let j = 0; j < this.containersRow[i].length; j++){
const children = this.getContainerChildren(i, j);
this.containersRow[i][j].removeAll(true);
this.containersRow[i][j].add(children);
const children = this.getContainerChildren(i, j);
this.containersRow[i][j].updateSprites(children);
}
}
}
@ -234,8 +242,20 @@ export class CustomizeScene extends AbstractCharacterScene {
this.moveLayers();
this.lazyloadingAttempt = false;
}
if (this.moveHorizontally !== 0) {
this.doMoveCursorHorizontally(this.moveHorizontally);
this.moveHorizontally = 0;
}
if (this.moveVertically !== 0) {
this.doMoveCursorVertically(this.moveVertically);
this.moveVertically = 0;
}
}
public onResize(): void {
this.moveLayers();
@ -243,7 +263,7 @@ export class CustomizeScene extends AbstractCharacterScene {
this.Rectangle.y = this.cameras.main.worldView.y + this.cameras.main.height / 3;
}
private nextSceneToCamera(){
public nextSceneToCamera(){
const layers: string[] = [];
let i = 0;
for (const layerItem of this.selectedLayers) {
@ -264,7 +284,7 @@ export class CustomizeScene extends AbstractCharacterScene {
customCharacterSceneVisibleStore.set(false);
}
private backToPreviousScene(){
public backToPreviousScene(){
this.scene.sleep(CustomizeSceneName);
waScaleManager.restoreZoom();
this.scene.run(SelectCharacterSceneName);

View file

@ -10,17 +10,13 @@ import {AbstractCharacterScene} from "./AbstractCharacterScene";
import {areCharacterLayersValid} from "../../Connexion/LocalUser";
import {touchScreenManager} from "../../Touch/TouchScreenManager";
import {PinchManager} from "../UserInput/PinchManager";
import {MenuScene} from "../Menu/MenuScene";
import {selectCharacterSceneVisibleStore} from "../../Stores/SelectCharacterStore";
import {customCharacterSceneVisibleStore} from "../../Stores/CustomCharacterStore";
import {waScaleManager} from "../Services/WaScaleManager";
import {isMobile} from "../../Enum/EnvironmentVariable";
//todo: put this constants in a dedicated file
export const SelectCharacterSceneName = "SelectCharacterScene";
const selectCharacterKey = 'selectCharacterScene';
export class SelectCharacterScene extends AbstractCharacterScene {
protected readonly nbCharactersPerRow = 6;
protected selectedPlayer!: Phaser.Physics.Arcade.Sprite|null; // null if we are selecting the "customize" option
@ -29,9 +25,9 @@ export class SelectCharacterScene extends AbstractCharacterScene {
protected selectedRectangle!: Rectangle;
protected selectCharacterSceneElement!: Phaser.GameObjects.DOMElement;
protected currentSelectUser = 0;
protected pointerClicked: boolean = false;
protected pointerTimer: number = 0;
protected lazyloadingAttempt = true; //permit to update texture loaded after renderer
@ -42,7 +38,6 @@ export class SelectCharacterScene extends AbstractCharacterScene {
}
preload() {
this.load.html(selectCharacterKey, 'resources/html/selectCharacterScene.html');
this.loadSelectSceneCharacters().then((bodyResourceDescriptions) => {
bodyResourceDescriptions.forEach((bodyResourceDescription) => {
@ -97,7 +92,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
});
}
protected nextSceneToCameraScene(): void {
public nextSceneToCameraScene(): void {
if (this.selectedPlayer !== null && !areCharacterLayersValid([this.selectedPlayer.texture.key])) {
return;
}
@ -113,7 +108,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
this.events.removeListener('wake');
}
protected nextSceneToCustomizeScene(): void {
public nextSceneToCustomizeScene(): void {
if (this.selectedPlayer !== null && !areCharacterLayersValid([this.selectedPlayer.texture.key])) {
return;
}
@ -127,6 +122,11 @@ export class SelectCharacterScene extends AbstractCharacterScene {
for (let i = 0; i <this.playerModels.length; i++) {
const playerResource = this.playerModels[i];
//check already exist texture
if(this.players.find((c) => c.texture.key === playerResource.name)){
continue;
}
const [middleX, middleY] = this.getCharacterPosition();
const player = this.physics.add.sprite(middleX, middleY, playerResource.name, 0);
this.setUpPlayer(player, i);
@ -137,13 +137,19 @@ export class SelectCharacterScene extends AbstractCharacterScene {
repeat: -1
});
player.setInteractive().on("pointerdown", () => {
if (this.pointerClicked || this.currentSelectUser === i) {
if (this.pointerClicked) {
return;
}
if (this.currentSelectUser === i) {
return;
}
//To not trigger two time the pointerdown events :
// We set a boolean to true so that pointerdown events does nothing when the boolean is true
// We set a timer that we decrease in update function to not trigger the pointerdown events twice
this.pointerClicked = true;
this.pointerTimer = 250;
this.currentSelectUser = i;
this.moveUser();
setTimeout(() => {this.pointerClicked = false;}, 100);
});
this.players.push(player);
}
@ -159,7 +165,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
this.updateSelectedPlayer();
}
protected moveToLeft(){
public moveToLeft(){
if(this.currentSelectUser === 0){
return;
}
@ -167,7 +173,7 @@ export class SelectCharacterScene extends AbstractCharacterScene {
this.moveUser();
}
protected moveToRight(){
public moveToRight(){
if(this.currentSelectUser === (this.players.length - 1)){
return;
}
@ -243,7 +249,16 @@ export class SelectCharacterScene extends AbstractCharacterScene {
}
update(time: number, delta: number): void {
// pointerTimer is set to 250 when pointerdown events is trigger
// After 250ms, pointerClicked is set to false and the pointerdown events can be trigger again
this.pointerTimer -= delta;
if (this.pointerTimer <= 0) {
this.pointerClicked = false;
}
if(this.lazyloadingAttempt){
//re-render players list
this.createCurrentPlayer();
this.moveUser();
this.lazyloadingAttempt = false;
}

View file

@ -23,6 +23,8 @@ export class SelectCompanionScene extends ResizableScene {
private saveZoom: number = 0;
private currentCompanion = 0;
private pointerClicked: boolean = false;
private pointerTimer: number = 0;
constructor() {
super({
@ -72,7 +74,12 @@ export class SelectCompanionScene extends ResizableScene {
}
update(time: number, delta: number): void {
// pointerTimer is set to 250 when pointerdown events is trigger
// After 250ms, pointerClicked is set to false and the pointerdown events can be trigger again
this.pointerTimer -= delta;
if (this.pointerTimer <= 0) {
this.pointerClicked = false;
}
}
public selectCompanion(): void {
@ -105,6 +112,14 @@ export class SelectCompanionScene extends ResizableScene {
});
companion.setInteractive().on("pointerdown", () => {
if (this.pointerClicked) {
return;
}
//To not trigger two time the pointerdown events :
// We set a boolean to true so that pointerdown events does nothing when the boolean is true
// We set a timer that we decrease in update function to not trigger the pointerdown events twice
this.pointerClicked = true;
this.pointerTimer = 250;
this.currentCompanion = i;
this.moveCompanion();
});

Some files were not shown because too many files have changed in this diff Show more