diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index fcde792a..1ad73027 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -39,7 +39,7 @@ jobs: working-directory: "messages" - name: "Build proto messages" - run: yarn run proto && yarn run copy-to-front + run: yarn run proto && yarn run copy-to-front && yarn run json-copy-to-front working-directory: "messages" - name: "Create index.html" @@ -97,7 +97,7 @@ jobs: working-directory: "messages" - name: "Build proto messages" - run: yarn run proto && yarn run copy-to-pusher + run: yarn run proto && yarn run copy-to-pusher && yarn run json-copy-to-pusher working-directory: "messages" - name: "Build" diff --git a/.github/workflows/end_to_end_tests.yml b/.github/workflows/end_to_end_tests.yml index a7b3ecfb..d6370f10 100644 --- a/.github/workflows/end_to_end_tests.yml +++ b/.github/workflows/end_to_end_tests.yml @@ -20,6 +20,15 @@ jobs: - name: "Checkout" uses: "actions/checkout@v2.0.0" + - name: "Setup NodeJS" + uses: actions/setup-node@v1 + with: + node-version: '14.x' + + - name: "Install dependencies" + run: npm install + working-directory: "tests" + - name: "Setup .env file" run: cp .env.template .env @@ -27,10 +36,10 @@ jobs: run: sudo chown 1000:1000 -R . - name: "Start environment" - run: docker-compose up -d + run: LIVE_RELOAD=0 docker-compose up -d - name: "Wait for environment to build (and downloading testcafe image)" - run: (docker-compose -f docker-compose.testcafe.yml pull &) && docker-compose logs -f --tail=0 front | grep -q "Compiled successfully" + run: (docker-compose -f docker-compose.testcafe.yml build &) && docker-compose logs -f --tail=0 front | grep -q "Compiled successfully" # - name: "temp debug: display logs" # run: docker-compose logs @@ -42,7 +51,7 @@ jobs: # run: docker-compose logs -f pusher | grep -q "WorkAdventure starting on port" - name: "Run tests" - run: docker-compose -f docker-compose.testcafe.yml up --exit-code-from testcafe + run: PROJECT_DIR=$(pwd) docker-compose -f docker-compose.testcafe.yml up --exit-code-from testcafe - name: Upload failed tests if: ${{ failure() }} diff --git a/.github/workflows/push-to-npm.yml b/.github/workflows/push-to-npm.yml index 1208e0c0..71f2824f 100644 --- a/.github/workflows/push-to-npm.yml +++ b/.github/workflows/push-to-npm.yml @@ -36,7 +36,7 @@ jobs: working-directory: "messages" - name: "Build proto messages" - run: yarn run proto && yarn run copy-to-front + run: yarn run proto && yarn run copy-to-front && yarn run json-copy-to-front working-directory: "messages" - name: "Create index.html" diff --git a/.husky/pre-commit b/.husky/pre-commit index 5944be37..8fa7767b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,10 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" +( +cd messages || exit +yarn run precommit +) ( cd front || exit yarn run precommit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bbbc93e..b3361333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,14 @@ 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. +## Contributing external resources + +You can share your work on maps / articles / videos related to WorkAdventure on our [awesome-workadventure](https://github.com/workadventure/awesome-workadventure) list. + +## Developer documentation + +Documentation targeted at developers can be found in the [`/docs/dev`](docs/dev/) + ## Using the issue tracker First things first: **Do NOT report security vulnerabilities in public issues!**. @@ -59,9 +67,43 @@ $ docker-compose exec back yarn run pretty 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). +Nevertheless, if your code can be unit tested, please provide a unit test (we use Jasmine), or an end-to-end test (we use Testcafe). 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. +some description text describing how to test the feature. +* if the features is meant to be manually tested, you should modify the `maps/tests/index.html` file to add a reference + to your newly created test map +* if the features can be automatically tested, please provide a testcafe test + +#### Running testcafe tests + +End-to-end tests are available in the "/tests" directory. + +To run these tests locally: + +```console +$ LIVE_RELOAD=0 docker-compose up -d +$ cd tests +$ npm install +$ npm run test +``` + +Note: If your tests fail on a Javascript error in "sockjs", this is due to the +Webpack live reload. The Webpack live reload feature is conflicting with testcafe. This is why we recommend starting +WorkAdventure with the `LIVE_RELOAD=0` environment variable. + +End-to-end tests can take a while to run. To run only one test, use: + +```console +$ npm run test -- tests/[name of the test file].ts +``` + +You can also run the tests inside a container (but you will not have visual feedbacks on your test, so we recommend using +the local tests). + +```console +$ LIVE_RELOAD=0 docker-compose up -d +# Wait 2-3 minutes for the environment to start, then: +$ PROJECT_DIR=$(pwd) docker-compose -f docker-compose.testcafe.yml up +``` diff --git a/README.md b/README.md index 427c514c..21871991 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ In WorkAdventure you can move around your office and talk to your colleagues (us See more features for your virtual office: https://workadventu.re/virtual-office +## Community resources + +Check out resources developed by the WorkAdventure community at [awesome-workadventure](https://github.com/workadventure/awesome-workadventure) + ## Setting up a development environment Install Docker. diff --git a/back/src/Controller/DebugController.ts b/back/src/Controller/DebugController.ts index 88287753..8fbf82e4 100644 --- a/back/src/Controller/DebugController.ts +++ b/back/src/Controller/DebugController.ts @@ -12,43 +12,52 @@ export class DebugController { getDump() { this.App.get("/dump", (res: HttpResponse, req: HttpRequest) => { - const query = parse(req.getQuery()); + (async () => { + const query = parse(req.getQuery()); - if (query.token !== ADMIN_API_TOKEN) { - return res.writeStatus("401 Unauthorized").end("Invalid token sent!"); - } + if (query.token !== ADMIN_API_TOKEN) { + return res.writeStatus("401 Unauthorized").end("Invalid token sent!"); + } - return res - .writeStatus("200 OK") - .writeHeader("Content-Type", "application/json") - .end( - stringify(socketManager.getWorlds(), (key: unknown, value: unknown) => { - if (key === "listeners") { - return "Listeners"; - } - if (key === "socket") { - return "Socket"; - } - if (key === "batchedMessages") { - return "BatchedMessages"; - } - if (value instanceof Map) { - const obj: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any - for (const [mapKey, mapValue] of value.entries()) { - obj[mapKey] = mapValue; + return res + .writeStatus("200 OK") + .writeHeader("Content-Type", "application/json") + .end( + stringify( + await Promise.all(socketManager.getWorlds().values()), + (key: unknown, value: unknown) => { + if (key === "listeners") { + return "Listeners"; + } + if (key === "socket") { + return "Socket"; + } + if (key === "batchedMessages") { + return "BatchedMessages"; + } + if (value instanceof Map) { + const obj: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any + for (const [mapKey, mapValue] of value.entries()) { + obj[mapKey] = mapValue; + } + return obj; + } else if (value instanceof Set) { + const obj: Array = []; + for (const [setKey, setValue] of value.entries()) { + obj.push(setValue); + } + return obj; + } else { + return value; + } } - return obj; - } else if (value instanceof Set) { - const obj: Array = []; - for (const [setKey, setValue] of value.entries()) { - obj.push(setValue); - } - return obj; - } else { - return value; - } - }) - ); + ) + ); + })().catch((e) => { + console.error(e); + res.writeStatus("500"); + res.end("An error occurred"); + }); }); } } diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 5c114f19..d708fba5 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -2,7 +2,13 @@ import { PointInterface } from "./Websocket/PointInterface"; import { Group } from "./Group"; import { User, UserSocket } from "./User"; import { PositionInterface } from "_Model/PositionInterface"; -import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; +import { + EmoteCallback, + EntersCallback, + LeavesCallback, + MovesCallback, + PlayerDetailsUpdatedCallback, +} from "_Model/Zone"; import { PositionNotifier } from "./PositionNotifier"; import { Movable } from "_Model/Movable"; import { @@ -11,6 +17,7 @@ import { EmoteEventMessage, ErrorMessage, JoinRoomMessage, + SetPlayerDetailsMessage, SubToPusherRoomMessage, VariableMessage, VariableWithTagMessage, @@ -56,10 +63,19 @@ export class GameRoom { onEnters: EntersCallback, onMoves: MovesCallback, onLeaves: LeavesCallback, - onEmote: EmoteCallback + onEmote: EmoteCallback, + onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback ) { // A zone is 10 sprites wide. - this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); + this.positionNotifier = new PositionNotifier( + 320, + 320, + onEnters, + onMoves, + onLeaves, + onEmote, + onPlayerDetailsUpdated + ); } public static async create( @@ -71,7 +87,8 @@ export class GameRoom { onEnters: EntersCallback, onMoves: MovesCallback, onLeaves: LeavesCallback, - onEmote: EmoteCallback + onEmote: EmoteCallback, + onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback ): Promise { const mapDetails = await GameRoom.getMapDetails(roomUrl); @@ -85,7 +102,8 @@ export class GameRoom { onEnters, onMoves, onLeaves, - onEmote + onEmote, + onPlayerDetailsUpdated ); return gameRoom; @@ -180,6 +198,14 @@ export class GameRoom { this.updateUserGroup(user); } + updatePlayerDetails(user: User, playerDetailsMessage: SetPlayerDetailsMessage) { + if (playerDetailsMessage.getRemoveoutlinecolor()) { + user.outlineColor = undefined; + } else { + user.outlineColor = playerDetailsMessage.getOutlinecolor(); + } + } + private updateUserGroup(user: User): void { user.group?.updatePosition(); user.group?.searchForNearbyUsers(); diff --git a/back/src/Model/PositionNotifier.ts b/back/src/Model/PositionNotifier.ts index 2052f229..b059999a 100644 --- a/back/src/Model/PositionNotifier.ts +++ b/back/src/Model/PositionNotifier.ts @@ -8,12 +8,19 @@ * The PositionNotifier is important for performance. It allows us to send the position of players only to a restricted * number of players around the current player. */ -import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback, Zone } from "./Zone"; +import { + EmoteCallback, + EntersCallback, + LeavesCallback, + MovesCallback, + PlayerDetailsUpdatedCallback, + Zone, +} from "./Zone"; import { Movable } from "_Model/Movable"; import { PositionInterface } from "_Model/PositionInterface"; import { ZoneSocket } from "../RoomManager"; import { User } from "../Model/User"; -import { EmoteEventMessage } from "../Messages/generated/messages_pb"; +import { EmoteEventMessage, SetPlayerDetailsMessage } from "../Messages/generated/messages_pb"; interface ZoneDescriptor { i: number; @@ -42,7 +49,8 @@ export class PositionNotifier { private onUserEnters: EntersCallback, private onUserMoves: MovesCallback, private onUserLeaves: LeavesCallback, - private onEmote: EmoteCallback + private onEmote: EmoteCallback, + private onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback ) {} private getZoneDescriptorFromCoordinates(x: number, y: number): ZoneDescriptor { @@ -98,7 +106,15 @@ export class PositionNotifier { let zone = this.zones[j][i]; if (zone === undefined) { - zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves, this.onEmote, i, j); + zone = new Zone( + this.onUserEnters, + this.onUserMoves, + this.onUserLeaves, + this.onEmote, + this.onPlayerDetailsUpdated, + i, + j + ); this.zones[j][i] = zone; } return zone; @@ -132,4 +148,11 @@ export class PositionNotifier { } } } + + public updatePlayerDetails(user: User, playerDetails: SetPlayerDetailsMessage) { + const position = user.getPosition(); + const zoneDesc = this.getZoneDescriptorFromCoordinates(position.x, position.y); + const zone = this.getZone(zoneDesc.i, zoneDesc.j); + zone.updatePlayerDetails(user, playerDetails); + } } diff --git a/back/src/Model/User.ts b/back/src/Model/User.ts index 186fb32a..a02ffde9 100644 --- a/back/src/Model/User.ts +++ b/back/src/Model/User.ts @@ -9,6 +9,7 @@ import { CompanionMessage, PusherToBackMessage, ServerToClientMessage, + SetPlayerDetailsMessage, SubMessage, } from "../Messages/generated/messages_pb"; import { CharacterLayer } from "_Model/Websocket/CharacterLayer"; @@ -31,7 +32,8 @@ export class User implements Movable { public readonly visitCardUrl: string | null, public readonly name: string, public readonly characterLayers: CharacterLayer[], - public readonly companion?: CompanionMessage + public readonly companion?: CompanionMessage, + private _outlineColor?: number | undefined ) { this.listenedZones = new Set(); @@ -69,4 +71,17 @@ export class User implements Movable { }, 100); } } + + public set outlineColor(value: number | undefined) { + this._outlineColor = value; + + const playerDetails = new SetPlayerDetailsMessage(); + if (value === undefined) { + playerDetails.setRemoveoutlinecolor(true); + } else { + playerDetails.setOutlinecolor(value); + } + + this.positionNotifier.updatePlayerDetails(this, playerDetails); + } } diff --git a/back/src/Model/Zone.ts b/back/src/Model/Zone.ts index d236e489..53f45464 100644 --- a/back/src/Model/Zone.ts +++ b/back/src/Model/Zone.ts @@ -3,12 +3,20 @@ import { PositionInterface } from "_Model/PositionInterface"; import { Movable } from "./Movable"; import { Group } from "./Group"; import { ZoneSocket } from "../RoomManager"; -import { EmoteEventMessage } from "../Messages/generated/messages_pb"; +import { + EmoteEventMessage, + SetPlayerDetailsMessage, + PlayerDetailsUpdatedMessage, +} from "../Messages/generated/messages_pb"; export type EntersCallback = (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => void; export type MovesCallback = (thing: Movable, position: PositionInterface, listener: ZoneSocket) => void; export type LeavesCallback = (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => void; export type EmoteCallback = (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => void; +export type PlayerDetailsUpdatedCallback = ( + playerDetailsUpdatedMessage: PlayerDetailsUpdatedMessage, + listener: ZoneSocket +) => void; export class Zone { private things: Set = new Set(); @@ -19,6 +27,7 @@ export class Zone { private onMoves: MovesCallback, private onLeaves: LeavesCallback, private onEmote: EmoteCallback, + private onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback, public readonly x: number, public readonly y: number ) {} @@ -106,4 +115,14 @@ export class Zone { this.onEmote(emoteEventMessage, listener); } } + + public updatePlayerDetails(user: User, playerDetails: SetPlayerDetailsMessage) { + const playerDetailsUpdatedMessage = new PlayerDetailsUpdatedMessage(); + playerDetailsUpdatedMessage.setUserid(user.id); + playerDetailsUpdatedMessage.setDetails(playerDetails); + + for (const listener of this.listeners) { + this.onPlayerDetailsUpdated(playerDetailsUpdatedMessage, listener); + } + } } diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 8dbde018..9f6b5d69 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -5,6 +5,7 @@ import { AdminPusherToBackMessage, AdminRoomMessage, BanMessage, + BanUserMessage, BatchToPusherMessage, BatchToPusherRoomMessage, EmotePromptMessage, @@ -16,7 +17,9 @@ import { QueryJitsiJwtMessage, RefreshRoomPromptMessage, RoomMessage, + SendUserMessage, ServerToAdminClientMessage, + SetPlayerDetailsMessage, SilentMessage, UserMovesMessage, VariableMessage, @@ -118,14 +121,17 @@ const roomManager: IRoomManagerServer = { ); } else if (message.hasSendusermessage()) { const sendUserMessage = message.getSendusermessage(); - if (sendUserMessage !== undefined) { - socketManager.handlerSendUserMessage(user, sendUserMessage); - } + socketManager.handleSendUserMessage(user, sendUserMessage as SendUserMessage); } else if (message.hasBanusermessage()) { const banUserMessage = message.getBanusermessage(); - if (banUserMessage !== undefined) { - socketManager.handlerBanUserMessage(room, user, banUserMessage); - } + socketManager.handlerBanUserMessage(room, user, banUserMessage as BanUserMessage); + } else if (message.hasSetplayerdetailsmessage()) { + const setPlayerDetailsMessage = message.getSetplayerdetailsmessage(); + socketManager.handleSetPlayerDetails( + room, + user, + setPlayerDetailsMessage as SetPlayerDetailsMessage + ); } else { throw new Error("Unhandled message type"); } @@ -251,7 +257,12 @@ const roomManager: IRoomManagerServer = { }, sendAdminMessage(call: ServerUnaryCall, callback: sendUnaryData): void { socketManager - .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()) + .sendAdminMessage( + call.request.getRoomid(), + call.request.getRecipientuuid(), + call.request.getMessage(), + call.request.getType() + ) .catch((e) => console.error(e)); callback(null, new EmptyMessage()); diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 5efae800..ce4ea413 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -33,6 +33,8 @@ import { VariableMessage, BatchToPusherRoomMessage, SubToPusherRoomMessage, + SetPlayerDetailsMessage, + PlayerDetailsUpdatedMessage, } from "../Messages/generated/messages_pb"; import { User, UserSocket } from "../Model/User"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; @@ -97,6 +99,7 @@ export class SocketManager { } const roomJoinedMessage = new RoomJoinedMessage(); roomJoinedMessage.setTagList(joinRoomMessage.getTagList()); + roomJoinedMessage.setUserroomtoken(joinRoomMessage.getUserroomtoken()); for (const [itemId, item] of room.getItemsState().entries()) { const itemStateMessage = new ItemStateMessage(); @@ -150,20 +153,9 @@ export class SocketManager { //room.setViewport(client, client.viewport); } - // Useless now, will be useful again if we allow editing details in game - /*handleSetPlayerDetails(client: UserSocket, playerDetailsMessage: SetPlayerDetailsMessage) { - const playerDetails = { - name: playerDetailsMessage.getName(), - characterLayers: playerDetailsMessage.getCharacterlayersList() - }; - //console.log(SocketIoEvent.SET_PLAYER_DETAILS, playerDetails); - if (!isSetPlayerDetailsMessage(playerDetails)) { - emitError(client, 'Invalid SET_PLAYER_DETAILS message received: '); - return; - } - client.name = playerDetails.name; - client.characterLayers = SocketManager.mergeCharacterLayersAndCustomTextures(playerDetails.characterLayers, client.textures); - }*/ + handleSetPlayerDetails(room: GameRoom, user: User, playerDetailsMessage: SetPlayerDetailsMessage) { + room.updatePlayerDetails(user, playerDetailsMessage); + } handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) { room.setSilent(user, silentMessage.getSilent()); @@ -281,7 +273,9 @@ export class SocketManager { (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => this.onClientLeave(thing, newZone, listener), (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => - this.onEmote(emoteEventMessage, listener) + this.onEmote(emoteEventMessage, listener), + (playerDetailsUpdatedMessage: PlayerDetailsUpdatedMessage, listener: ZoneSocket) => + this.onPlayerDetailsUpdated(playerDetailsUpdatedMessage, listener) ) .then((gameRoom) => { gaugeManager.incNbRoomGauge(); @@ -328,6 +322,12 @@ export class SocketManager { userJoinedZoneMessage.setVisitcardurl(thing.visitCardUrl); } userJoinedZoneMessage.setCompanion(thing.companion); + if (thing.outlineColor === undefined) { + userJoinedZoneMessage.setHasoutline(false); + } else { + userJoinedZoneMessage.setHasoutline(true); + userJoinedZoneMessage.setOutlinecolor(thing.outlineColor); + } const subMessage = new SubToPusherMessage(); subMessage.setUserjoinedzonemessage(userJoinedZoneMessage); @@ -377,6 +377,13 @@ export class SocketManager { emitZoneMessage(subMessage, client); } + private onPlayerDetailsUpdated(playerDetailsUpdatedMessage: PlayerDetailsUpdatedMessage, client: ZoneSocket) { + const subMessage = new SubToPusherMessage(); + subMessage.setPlayerdetailsupdatedmessage(playerDetailsUpdatedMessage); + + emitZoneMessage(subMessage, client); + } + private emitCreateUpdateGroupEvent(client: ZoneSocket, fromZone: Zone | null, group: Group): void { const position = group.getPosition(); const pointMessage = new PointMessage(); @@ -571,7 +578,7 @@ export class SocketManager { user.socket.write(serverToClientMessage); } - public handlerSendUserMessage(user: User, sendUserMessageToSend: SendUserMessage) { + public handleSendUserMessage(user: User, sendUserMessageToSend: SendUserMessage) { const sendUserMessage = new SendUserMessage(); sendUserMessage.setMessage(sendUserMessageToSend.getMessage()); sendUserMessage.setType(sendUserMessageToSend.getType()); @@ -690,7 +697,7 @@ export class SocketManager { } } - public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise { + public async sendAdminMessage(roomId: string, recipientUuid: string, message: string, type: string): Promise { const room = await this.roomsPromises.get(roomId); if (!room) { console.error( @@ -714,7 +721,7 @@ export class SocketManager { for (const recipient of recipients) { const sendUserMessage = new SendUserMessage(); sendUserMessage.setMessage(message); - sendUserMessage.setType("ban"); //todo: is the type correct? + sendUserMessage.setType(type); const serverToClientMessage = new ServerToClientMessage(); serverToClientMessage.setSendusermessage(sendUserMessage); diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index 00aac3dc..6ec1cc3a 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -1,12 +1,7 @@ /** * Handles variables shared between the scripting API and the server. */ -import { - ITiledMap, - ITiledMapLayer, - ITiledMapObject, - ITiledMapObjectLayer, -} from "@workadventure/tiled-map-type-guard/dist"; +import { ITiledMap, ITiledMapLayer, ITiledMapObject } from "@workadventure/tiled-map-type-guard/dist"; import { User } from "_Model/User"; import { variablesRepository } from "./Repository/VariablesRepository"; import { redisClient } from "./RedisClient"; diff --git a/back/tests/GameRoomTest.ts b/back/tests/GameRoomTest.ts index 7540ad94..d4e83daf 100644 --- a/back/tests/GameRoomTest.ts +++ b/back/tests/GameRoomTest.ts @@ -51,7 +51,8 @@ describe("GameRoom", () => { () => {}, () => {}, () => {}, - emote + emote, + () => {} ); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100)); @@ -86,7 +87,8 @@ describe("GameRoom", () => { () => {}, () => {}, () => {}, - emote + emote, + () => {} ); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100)); @@ -125,7 +127,8 @@ describe("GameRoom", () => { () => {}, () => {}, () => {}, - emote + emote, + () => {} ); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100)); diff --git a/back/tests/PositionNotifierTest.ts b/back/tests/PositionNotifierTest.ts index 1aaf2e13..c081f1b4 100644 --- a/back/tests/PositionNotifierTest.ts +++ b/back/tests/PositionNotifierTest.ts @@ -19,7 +19,8 @@ describe("PositionNotifier", () => { moveTriggered = true; }, (thing: Movable) => { leaveTriggered = true; - }, () => {}); + }, () => {}, + () => {}); const user1 = new User(1, 'test', '10.0.0.2', { x: 500, @@ -94,7 +95,8 @@ describe("PositionNotifier", () => { moveTriggered = true; }, (thing: Movable) => { leaveTriggered = true; - }, () => {}); + }, () => {}, + () => {}); const user1 = new User(1, 'test', '10.0.0.2', { x: 500, diff --git a/deeployer.libsonnet b/deeployer.libsonnet index d3280ed2..0bbda264 100644 --- a/deeployer.libsonnet +++ b/deeployer.libsonnet @@ -101,7 +101,10 @@ "host": { "url": "maps-"+url }, - "ports": [80] + "ports": [80], + "env": { + "FRONT_URL": "https://play-"+url + } }, "redis": { "image": "redis:6", diff --git a/docker-compose.single-domain.yaml b/docker-compose.single-domain.yaml index 59752cb9..1612e396 100644 --- a/docker-compose.single-domain.yaml +++ b/docker-compose.single-domain.yaml @@ -92,11 +92,12 @@ services: - "traefik.http.routers.pusher-ssl.service=pusher" maps: - image: thecodingmachine/nodejs:12-apache + image: thecodingmachine/php:8.1-v4-apache-node12 environment: DEBUG_MODE: "$DEBUG_MODE" HOST: "0.0.0.0" NODE_ENV: development + FRONT_URL: http://play.workadventure.localhost #APACHE_DOCUMENT_ROOT: dist/ #APACHE_EXTENSIONS: headers #APACHE_EXTENSION_HEADERS: 1 diff --git a/docker-compose.testcafe.yml b/docker-compose.testcafe.yml index 774477a1..e61db21d 100644 --- a/docker-compose.testcafe.yml +++ b/docker-compose.testcafe.yml @@ -1,12 +1,19 @@ -version: "3" +version: "3.5" services: testcafe: - image: testcafe/testcafe:1.17.1 - working_dir: /tests + build: tests/ + working_dir: /project/tests + command: + - --dev + # Run as root to have the right to access /var/run/docker.sock + user: root environment: BROWSER: "chromium --use-fake-device-for-media-stream" + PROJECT_DIR: ${PROJECT_DIR} + ADMIN_API_TOKEN: ${ADMIN_API_TOKEN} volumes: - - ./tests:/tests + - ./:/project - ./maps:/maps + - /var/run/docker.sock:/var/run/docker.sock # security_opt: # - seccomp:unconfined diff --git a/docker-compose.yaml b/docker-compose.yaml index b8fa15ab..17e04f7c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,20 +1,21 @@ -version: "3" +version: "3.5" services: reverse-proxy: - image: traefik:v2.0 + image: traefik:v2.5 command: - --api.insecure=true - --providers.docker - --entryPoints.web.address=:80 - --entryPoints.websecure.address=:443 + - "--providers.docker.exposedbydefault=false" ports: - "80:80" - "443:443" # The Web UI (enabled by --api.insecure=true) - "8080:8080" - depends_on: - - back - - front + #depends_on: + # - back + # - front volumes: - /var/run/docker.sock:/var/run/docker.sock networks: @@ -51,10 +52,12 @@ services: MAX_USERNAME_LENGTH: "$MAX_USERNAME_LENGTH" DISABLE_ANONYMOUS: "$DISABLE_ANONYMOUS" OPID_LOGIN_SCREEN_PROVIDER: "$OPID_LOGIN_SCREEN_PROVIDER" + LIVE_RELOAD: "$LIVE_RELOAD:-true" command: yarn run start volumes: - ./front:/usr/src/app labels: + - "traefik.enable=true" - "traefik.http.routers.front.rule=Host(`play.workadventure.localhost`)" - "traefik.http.routers.front.entryPoints=web" - "traefik.http.services.front.loadbalancer.server.port=8080" @@ -87,6 +90,7 @@ services: volumes: - ./pusher:/usr/src/app labels: + - "traefik.enable=true" - "traefik.http.routers.pusher.rule=Host(`pusher.workadventure.localhost`)" - "traefik.http.routers.pusher.entryPoints=web" - "traefik.http.services.pusher.loadbalancer.server.port=8080" @@ -96,11 +100,12 @@ services: - "traefik.http.routers.pusher-ssl.service=pusher" maps: - image: thecodingmachine/nodejs:12-apache + image: thecodingmachine/php:8.1-v4-apache-node12 environment: DEBUG_MODE: "$DEBUG_MODE" HOST: "0.0.0.0" NODE_ENV: development + FRONT_URL: http://play.workadventure.localhost #APACHE_DOCUMENT_ROOT: dist/ #APACHE_EXTENSIONS: headers #APACHE_EXTENSION_HEADERS: 1 @@ -110,6 +115,7 @@ services: volumes: - ./maps:/var/www/html labels: + - "traefik.enable=true" - "traefik.http.routers.maps.rule=Host(`maps.workadventure.localhost`)" - "traefik.http.routers.maps.entryPoints=web,traefik" - "traefik.http.services.maps.loadbalancer.server.port=80" @@ -141,6 +147,7 @@ services: volumes: - ./back:/usr/src/app labels: + - "traefik.enable=true" - "traefik.http.routers.back.rule=Host(`api.workadventure.localhost`)" - "traefik.http.routers.back.entryPoints=web" - "traefik.http.services.back.loadbalancer.server.port=8080" @@ -159,6 +166,7 @@ services: volumes: - ./uploader:/usr/src/app labels: + - "traefik.enable=true" - "traefik.http.routers.uploader.rule=Host(`uploader.workadventure.localhost`)" - "traefik.http.routers.uploader.entryPoints=web" - "traefik.http.services.uploader.loadbalancer.server.port=8080" @@ -186,6 +194,7 @@ services: redisinsight: image: redislabs/redisinsight:latest labels: + - "traefik.enable=true" - "traefik.http.routers.redisinsight.rule=Host(`redis.workadventure.localhost`)" - "traefik.http.routers.redisinsight.entryPoints=web" - "traefik.http.services.redisinsight.loadbalancer.server.port=8001" @@ -197,6 +206,7 @@ services: icon: image: matthiasluedtke/iconserver:v3.13.0 labels: + - "traefik.enable=true" - "traefik.http.routers.icon.rule=Host(`icon.workadventure.localhost`)" - "traefik.http.routers.icon.entryPoints=web" - "traefik.http.services.icon.loadbalancer.server.port=8080" diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 00000000..d05c4884 --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,16 @@ +# Developer documentation + +This (work in progress) documentation provides a number of "how-to" guides explaining how to work on the WorkAdventure +code. + +This documentation is targeted at developers looking to open Pull Requests on WorkAdventure. + +If you "only" want to design dynamic maps, please refer instead to the [scripting API documentation](https://workadventu.re/map-building/scripting.md). + +## Contributing + +Check out the [contributing guide](../../CONTRIBUTING.md) + +## Front documentation + +- [How to add new functions in the scripting API](contributing-to-scripting-api.md) diff --git a/docs/dev/contributing-to-scripting-api.md b/docs/dev/contributing-to-scripting-api.md new file mode 100644 index 00000000..8d716010 --- /dev/null +++ b/docs/dev/contributing-to-scripting-api.md @@ -0,0 +1,276 @@ +# How to add new functions in the scripting API + +This documentation is intended at contributors who want to participate in the development of WorkAdventure itself. +Before reading this, please be sure you are familiar with the [scripting API](https://workadventu.re/map-building/scripting.md). + +The [scripting API](https://workadventu.re/map-building/scripting.md) allows map developers to add dynamic features in their maps. + +## Why extend the scripting API? + +The philosophy behind WorkAdventure is to build a platform that is as open as possible. Part of this strategy is to +offer map developers the ability to turn a WorkAdventures map into something unexpected, using the API. For instance, +you could use it to develop games (we have seen a PacMan and a mine-sweeper on WorkAdventure!) + +We started working on the WorkAdventure scripting API with this in mind, but at some point, maybe you will find that +a feature is missing in the API. This article is here to explain to you how to add this feature. + +## How to extend the scripting API? + +Extending the scripting API means modifying the core of WorkAdventure. You can of course run these +modifications on your self-hosted instance. +But if you want to share it with the wider community, I strongly encourage you to start by [opening an issue](https://github.com/thecodingmachine/workadventure/issues) +on GitHub before starting the development. Check with the core maintainers that they are willing to merge your idea +before starting developing it. Once a new function makes it into the scripting API, it is very difficult to make it +evolve (or to deprecate), so the design of the function you add needs to be carefully considered. + +## How does it work? + +Scripts are executed in the browser, inside an iframe. + +![](images/scripting_1.svg) + +The iframe allows WorkAdventure to isolate the script in a sandbox. Because the iframe is sandbox (or on a different +domain than the WorkAdventure server), scripts cannot directly manipulate the DOM of WorkAdventure. They also cannot +directly access Phaser objects (Phaser is the game engine used in WorkAdventure). This is by-design. Since anyone +can contribute a map, we cannot allow anyone to run any code in the scope of the WorkAdventure server (that would be +a huge XSS security flaw). + +Instead, the only way the script can interact with WorkAdventure is by sending messages using the +[postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). + +![](images/scripting_2.svg) + +We want to make life easy for map developers. So instead of asking them to directly send messages using the postMessage +API, we provide a nice library that does this work for them. This library is what we call the "Scripting API" (we sometimes +refer to it as the "Client API"). + +The scripting API provides the global `WA` object. + +## A simple example + +So let's take an example with a sample script: + +```typescript +WA.chat.sendChatMessage('Hello world!', 'John Doe'); +``` + +When this script is called, the scripting API is dispatching a JSON message to WorkAdventure. + +In our case, the `sendChatMessage` function looks like this: + +**src/Api/iframe/chat.ts** +```typescript + sendChatMessage(message: string, author: string) { + sendToWorkadventure({ + type: "chat", + data: { + message: message, + author: author, + }, + }); + } +``` + +The `sendToWorkadventure` function is a utility function that dispatches the message to the main frame. + +In WorkAdventure, the message is received in the [`IframeListener` listener class](http://github.com/thecodingmachine/workadventure/blob/1e6ce4dec8697340e2c91798864b94da9528b482/front/src/Api/IframeListener.ts#L200-L203). +This class is in charge of analyzing the JSON messages received and dispatching them to the right place in the WorkAdventure application. + +The message callback implemented in `IframeListener` is a giant (and disgusting) `if` statement branching to the correct +part of the code depending on the `type` property. + +**src/Api/IframeListener.ts** +```typescript +// ... + } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { + this._setPropertyStream.next(payload.data); + } else if (payload.type === "chat" && isChatEvent(payload.data)) { + scriptUtils.sendAnonymousChat(payload.data); + } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { + this._openPopupStream.next(payload.data); + } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { +// ... +``` + +In this particular case, we call `scriptUtils.sendAnonymousChat` that is doing the work of displaying the chat message. + +## Scripting API entry point + +The `WA` object originates from the scripting API. This script is hosted on the front server, at `https://[front_WA_server]/iframe_api.js.`. + +The entry point for this script is the file `front/src/iframe_api.ts`. +All the other files dedicated to the iframe API are located in the `src/Api/iframe` directory. + +## Utility functions to exchange messages + +In the example above, we already saw you can easily send a message from the iframe to WorkAdventure using the +[`sendToWorkadventure`](http://github.com/thecodingmachine/workadventure/blob/ab075ef6f4974766a3e2de12a230ac4df0954b58/front/src/Api/iframe/IframeApiContribution.ts#L11-L13) utility function. + +Of course, messaging can go the other way around and WorkAdventure can also send messages to the iframes. +We use the [`IFrameListener.postMessage`](http://github.com/thecodingmachine/workadventure/blob/ab075ef6f4974766a3e2de12a230ac4df0954b58/front/src/Api/IframeListener.ts#L455-L459) function for this. + +Finally, there is a last type of utility function (a quite powerful one). It is quite common to need to call a function +from the iframe in WorkAdventure, and to expect a response. For those use cases, the iframe API comes with a +[`queryWorkadventure`](http://github.com/thecodingmachine/workadventure/blob/ab075ef6f4974766a3e2de12a230ac4df0954b58/front/src/Api/iframe/IframeApiContribution.ts#L30-L49) utility function. + +## Types + +The JSON messages sent over the postMessage API are strictly defined using Typescript types. +Those types are not defined using classical Typescript interfaces. + +Indeed, Typescript interfaces only exist at compilation time but cannot be enforced on runtime. The postMessage API +is an entry point to WorkAdventure, and as with any entry point, data must be checked (otherwise, a hacker could +send specially crafted JSON packages to try to hack WA). + +In WorkAdventure, we use the [generic-type-guard](https://github.com/mscharley/generic-type-guard) package. This package +allows us to create interfaces AND custom type guards in one go. + +Let's go back at our example. Let's have a look at the JSON message sent when we want to send a chat message from the API: + +```typescript +sendToWorkadventure({ + type: "chat", + data: { + message: message, + author: author, + }, +}); +``` + +The "data" part of the message is defined in `front/src/Api/Events/ChatEvent.ts`: + +```typescript +import * as tg from "generic-type-guard"; + +export const isChatEvent = new tg.IsInterface() + .withProperties({ + message: tg.isString, + author: tg.isString, + }) + .get(); +/** + * A message sent from the iFrame to the game to add a message in the chat. + */ +export type ChatEvent = tg.GuardedType; +``` + +Using the generic-type-guard library, we start by writing a type guard function (`isChatEvent`). +From this type guard, the library can automatically generate the `ChatEvent` type that we can refer in our code. + +The advantage of this technique is that, **at runtime**, WorkAdventure can verify that the JSON message received +over the postMessage API is indeed correctly formatted. + +If you are not familiar with Typescript type guards, you can read [an introduction to type guards in the Typescript documentation](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards). + +### Typing one way messages + +For "one-way" messages (from the iframe to WorkAdventure), the `sendToWorkadventure` method expects the passed +object to be of type `IframeEvent`. + +Note: I'd like here to thank @jonnytest1 for helping set up this type system. It rocks ;) + +The `IFrameEvent` type is defined in `front/src/Api/Events/IframeEvent.ts`: + +```typescript +export type IframeEventMap = { + loadPage: LoadPageEvent; + chat: ChatEvent; + openPopup: OpenPopupEvent; + closePopup: ClosePopupEvent; + openTab: OpenTabEvent; + // ... + // All the possible messages go here + // The key goes into the "type" JSON property + // ... +}; +export interface IframeEvent { + type: T; + data: IframeEventMap[T]; +} +``` + +Similarly, if you want to type messages from WorkAdventure to the iframe, there is a very similar `IframeResponseEvent`. + +```typescript +export interface IframeResponseEventMap { + userInputChat: UserInputChatEvent; + enterEvent: EnterLeaveEvent; + leaveEvent: EnterLeaveEvent; + // ... + // All the possible messages go here + // The key goes into the "type" JSON property + // ... +} +export interface IframeResponseEvent { + type: T; + data: IframeResponseEventMap[T]; +} +``` + +### Typing queries (messages with answers) + +If you want to add a new "query" (if you are using the `queryWorkadventure` utility function), you will need to +define the type of the query and the type of the response. + +The signature of `queryWorkadventure` is: + +```typescript +function queryWorkadventure( + content: IframeQuery +): Promise +``` + +Yes, that's a bit cryptic. Hopefully, all you need to know is that to add a new query, you need to edit the `iframeQueryMapTypeGuards` +array in `front/src/Api/Events/IframeEvent.ts`: + +```typescript +export const iframeQueryMapTypeGuards = { + openCoWebsite: { + query: isOpenCoWebsiteEvent, + answer: isCoWebsite, + }, + getCoWebsites: { + query: tg.isUndefined, + answer: tg.isArray(isCoWebsite), + }, + // ... + // the `query` key points to the type guard of the query + // the `answer` key points to the type guard of the response +}; +``` + +### Responding to a query on the WorkAdventure side + +In the WorkAdventure code, each possible query should be handled by what we call an "answerer". + +Registering an answerer happens using the `iframeListener.registerAnswerer()` method. + +Here is a sample: + +```typescript +iframeListener.registerAnswerer("openCoWebsite", (openCoWebsiteEvent, source) => { + // ... + + return /*...*/; +}); +``` + +The `registerAnswerer` callback is passed the event, and should return a response (or a promise to the response) in the expected format +(the one you defined in the `answer` key of `iframeQueryMapTypeGuards`). + +Important: + +- there can be only one answerer registered for a given query type. +- if the answerer is not valid any more, you need to unregister the answerer using `iframeListener.unregisterAnswerer`. + + +## sendToWorkadventure VS queryWorkadventure + +- `sendToWorkadventure` is used to send messages one way from the iframe to WorkAdventure. No response is expected. In particular + if an error happens in WorkAdventure, the iframe will not be notified. +- `queryWorkadventure` is used to send queries that expect an answer. If an error happens in WorkAdventure (i.e. if an + exception is raised), the exception will be propagated to the iframe. + +Because `queryWorkadventure` handles exceptions properly, it can be interesting to use `queryWorkadventure` instead +of `sendToWorkadventure`, even for "one-way" messages. The return message type is simply `undefined` in this case. + diff --git a/docs/dev/images/scripting_1.svg b/docs/dev/images/scripting_1.svg new file mode 100644 index 00000000..cae529f3 --- /dev/null +++ b/docs/dev/images/scripting_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/dev/images/scripting_2.svg b/docs/dev/images/scripting_2.svg new file mode 100644 index 00000000..a07294f4 --- /dev/null +++ b/docs/dev/images/scripting_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/maps/api-player.md b/docs/maps/api-player.md index 39a13d9e..35d5f464 100644 --- a/docs/maps/api-player.md +++ b/docs/maps/api-player.md @@ -58,6 +58,34 @@ WA.onInit().then(() => { }) ``` +### Get the user-room token of the player + +``` +WA.player.userRoomToken: string; +``` + +The user-room token is available from the `WA.player.userRoomToken` property. + +This token can be used by third party services to authenticate a player and prove that the player is in a given room. +The token is generated by the administration panel linked to WorkAdventure. The token is a string and is depending on your implementation of the administration panel. +In WorkAdventure SAAS version, the token is a JWT token that contains information such as the player's room ID and its associated membership ID. + +If you are using the self-hosted version of WorkAdventure and you developed your own administration panel, the token can be anything. +By default, self-hosted versions of WorkAdventure don't come with an administration panel, so the token string will be empty. + +{.alert.alert-info} +A typical use-case for the user-room token is providing logo upload capabilities in a map. +The token can be used as a way to authenticate a WorkAdventure player and ensure he is indeed in the map and authorized to upload a logo. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.userRoomToken` + +```typescript +WA.onInit().then(() => { + console.log('Token: ', WA.player.userRoomToken); +}) +``` + ### Listen to player movement ``` WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void; @@ -78,3 +106,25 @@ Example : ```javascript WA.player.onPlayerMove(console.log); ``` + +### Set the outline color of the player +``` +WA.player.setOutlineColor(red: number, green: number, blue: number): Promise; +WA.player.removeOutlineColor(): Promise; +``` + +You can display a thin line around your player's name (the "outline"). + +Use `setOutlineColor` to set the outline and `removeOutlineColor` to remove it. + +Colors are expressed in RGB. Each parameter is an integer between 0 and 255. + +```typescript +// Let's add a red outline to our player +WA.player.setOutlineColor(255, 0, 0); +``` + +When you set the outline on your player, other players will see the outline too (the outline color is shared across +browsers automatically). + +![](images/outlines.png) diff --git a/docs/maps/camera.md b/docs/maps/camera.md new file mode 100644 index 00000000..9e58fcad --- /dev/null +++ b/docs/maps/camera.md @@ -0,0 +1,92 @@ +{.section-title.accent.text-primary} +# Working with camera + +## Focusable Zones + +It is possible to define special regions on the map that can make the camera zoom and center on themselves. We call them "Focusable Zones". When player gets inside, his camera view will be altered - focused, zoomed and locked on defined zone, like this: + +
+ +
+ +### Adding new **Focusable Zone**: + +1. Make sure you are editing an **Object Layer** + +
+ +
+ +2. Select **Insert Rectangle** tool + +
+ +
+ +3. Define new object wherever you want. For example, you can make your chilling room event cosier! + +
+ +
+ +4. Make sure your object is of type "zone"! + +
+ +
+ +5. Edit this new object and click on **Add Property**, like this: + +
+ +
+ +6. Add a **bool** property of name *focusable*: + +
+ +
+ +7. Make sure it's checked! :) + +
+ +
+ +All should be set up now and your new **Focusable Zone** should be working fine! + +### Defining custom zoom margin: + +If you want, you can add an additional property to control how much should the camera zoom onto focusable zone. + +1. Like before, click on **Add Property** + +
+ +
+ +2. Add a **float** property of name *zoom_margin*: + +
+ +
+ +2. Define how much (in percentage value) should the zoom be decreased: + +
+ +
+ + For example, if you define your zone as a 300x200 rectangle, setting this property to 0.5 *(50%)* means the camera will try to fit within the viewport the entire zone + margin of 50% of its dimensions, so 450x300. + + - No margin defined + +
+ +
+ + - Margin set to **0.35** + +
+ +
\ No newline at end of file diff --git a/docs/maps/images/camera/0_focusable_zone.png b/docs/maps/images/camera/0_focusable_zone.png new file mode 100644 index 00000000..8b54f11f Binary files /dev/null and b/docs/maps/images/camera/0_focusable_zone.png differ diff --git a/docs/maps/images/camera/1_object_layer.png b/docs/maps/images/camera/1_object_layer.png new file mode 100644 index 00000000..6f57d0ae Binary files /dev/null and b/docs/maps/images/camera/1_object_layer.png differ diff --git a/docs/maps/images/camera/2_rectangle_zone.png b/docs/maps/images/camera/2_rectangle_zone.png new file mode 100644 index 00000000..9b0b9cda Binary files /dev/null and b/docs/maps/images/camera/2_rectangle_zone.png differ diff --git a/docs/maps/images/camera/3_define_new_zone.png b/docs/maps/images/camera/3_define_new_zone.png new file mode 100644 index 00000000..226028eb Binary files /dev/null and b/docs/maps/images/camera/3_define_new_zone.png differ diff --git a/docs/maps/images/camera/4_add_zone_type.png b/docs/maps/images/camera/4_add_zone_type.png new file mode 100644 index 00000000..0416d1e4 Binary files /dev/null and b/docs/maps/images/camera/4_add_zone_type.png differ diff --git a/docs/maps/images/camera/5_click_add_property.png b/docs/maps/images/camera/5_click_add_property.png new file mode 100644 index 00000000..9aa96a2f Binary files /dev/null and b/docs/maps/images/camera/5_click_add_property.png differ diff --git a/docs/maps/images/camera/6_add_focusable_prop.png b/docs/maps/images/camera/6_add_focusable_prop.png new file mode 100644 index 00000000..3ba1b955 Binary files /dev/null and b/docs/maps/images/camera/6_add_focusable_prop.png differ diff --git a/docs/maps/images/camera/7_make_sure_checked.png b/docs/maps/images/camera/7_make_sure_checked.png new file mode 100644 index 00000000..7fbcdb89 Binary files /dev/null and b/docs/maps/images/camera/7_make_sure_checked.png differ diff --git a/docs/maps/images/camera/8_add_zoom_margin.png b/docs/maps/images/camera/8_add_zoom_margin.png new file mode 100644 index 00000000..8e3f5256 Binary files /dev/null and b/docs/maps/images/camera/8_add_zoom_margin.png differ diff --git a/docs/maps/images/camera/9_optional_zoom_margin_defined.png b/docs/maps/images/camera/9_optional_zoom_margin_defined.png new file mode 100644 index 00000000..8b41d7d0 Binary files /dev/null and b/docs/maps/images/camera/9_optional_zoom_margin_defined.png differ diff --git a/docs/maps/images/camera/no_margin.png b/docs/maps/images/camera/no_margin.png new file mode 100644 index 00000000..b8c9dd18 Binary files /dev/null and b/docs/maps/images/camera/no_margin.png differ diff --git a/docs/maps/images/camera/with_margin.png b/docs/maps/images/camera/with_margin.png new file mode 100644 index 00000000..ffd057ea Binary files /dev/null and b/docs/maps/images/camera/with_margin.png differ diff --git a/docs/maps/images/mapProperties.png b/docs/maps/images/mapProperties.png new file mode 100644 index 00000000..d4001da4 Binary files /dev/null and b/docs/maps/images/mapProperties.png differ diff --git a/docs/maps/menu.php b/docs/maps/menu.php index 0bf0a7f9..10a2f4c5 100644 --- a/docs/maps/menu.php +++ b/docs/maps/menu.php @@ -51,6 +51,12 @@ return [ 'markdown' => 'maps.website-in-map', 'editUrl' => 'https://github.com/thecodingmachine/workadventure/edit/develop/docs/maps/website-in-map.md', ], + [ + 'title' => 'Camera', + 'url' => '/map-building/camera.md', + 'markdown' => 'maps.camera', + 'editUrl' => 'https://github.com/thecodingmachine/workadventure/edit/develop/docs/maps/camera.md', + ], [ 'title' => 'Variables', 'url' => '/map-building/variables.md', diff --git a/docs/maps/scripting.md b/docs/maps/scripting.md index 8b11fe74..6da3ddbf 100644 --- a/docs/maps/scripting.md +++ b/docs/maps/scripting.md @@ -60,7 +60,7 @@ WA.chat.sendChatMessage('Hello world', 'Mr Robot'); The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.chat.sendChatMessage` opens the chat and adds a message in it. -In your browser console, when you open the map, the chat message should be displayed right away. +The message should be displayed in the chat history as soon as you enter the room. ## Adding a script in an iFrame diff --git a/docs/maps/wa-maps.md b/docs/maps/wa-maps.md index 36875719..6e84a251 100644 --- a/docs/maps/wa-maps.md +++ b/docs/maps/wa-maps.md @@ -92,3 +92,20 @@ You can add properties either on individual tiles of a tileset OR on a complete 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. + +## Insert helpful information in your map + +By setting properties on the map itself, you can help visitors know more about the creators of the map. + +The following *map* properties are supported: +* `mapName` (string): The name of your map +* `mapLink` (string): A link to your map, for example a repository +* `mapDescription` (string): A short description of your map +* `mapCopyright` (string): Copyright notice + +Each *tileset* can also have a property called `tilesetCopyright` (string). +If you are using audio files in your map, you can declare a layer property `audioCopyright` (string). + +Resulting in a "credit" page in the menu looking like this: + +![](images/mapProperties.png){.document-img} diff --git a/front/.eslintrc.json b/front/.eslintrc.js similarity index 72% rename from front/.eslintrc.json rename to front/.eslintrc.js index 45b44456..117cb7e6 100644 --- a/front/.eslintrc.json +++ b/front/.eslintrc.js @@ -1,4 +1,4 @@ -{ +module.exports = { "root": true, "env": { "browser": true, @@ -18,10 +18,18 @@ "parserOptions": { "ecmaVersion": 2018, "sourceType": "module", - "project": "./tsconfig.json" + "project": "./tsconfig.json", + "extraFileExtensions": [".svelte"] }, "plugins": [ - "@typescript-eslint" + "@typescript-eslint", + "svelte3" + ], + "overrides": [ + { + "files": ["*.svelte"], + "processor": "svelte3/svelte3" + } ], "rules": { "no-unused-vars": "off", @@ -33,6 +41,11 @@ "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/restrict-template-expressions": "off" + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/no-unsafe-argument": "off", + }, + "settings": { + "svelte3/typescript": true, + "svelte3/ignore-styles": () => true } } diff --git a/front/.prettierignore b/front/.prettierignore index 1f453464..26de759f 100644 --- a/front/.prettierignore +++ b/front/.prettierignore @@ -1 +1,2 @@ src/Messages/generated +src/Messages/JsonMessages diff --git a/front/.prettierrc.json b/front/.prettierrc.json index e8980d15..057ed062 100644 --- a/front/.prettierrc.json +++ b/front/.prettierrc.json @@ -1,4 +1,5 @@ { "printWidth": 120, - "tabWidth": 4 + "tabWidth": 4, + "plugins": ["prettier-plugin-svelte"] } diff --git a/front/Dockerfile b/front/Dockerfile index 6fef9dc8..f781a37c 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -8,6 +8,7 @@ FROM thecodingmachine/nodejs:14-apache COPY --chown=docker:docker front . COPY --from=builder --chown=docker:docker /usr/src/generated /var/www/html/src/Messages/generated +COPY --from=builder --chown=docker:docker /usr/src/JsonMessages /var/www/html/src/Messages/JsonMessages # Removing the iframe.html file from the final image as this adds a XSS attack. # iframe.html is only in dev mode to circumvent a limitation diff --git a/front/dist/iframe.html b/front/dist/iframe.html index c8fafb4b..02dc0fd8 100644 --- a/front/dist/iframe.html +++ b/front/dist/iframe.html @@ -11,6 +11,7 @@ const scriptUrl = urlParams.get('script'); const script = document.createElement('script'); script.src = scriptUrl; + script.type = "module"; document.head.append(script); diff --git a/front/package.json b/front/package.json index 8e616aae..eae92cd2 100644 --- a/front/package.json +++ b/front/package.json @@ -12,11 +12,12 @@ "@types/quill": "^1.3.7", "@types/uuidv4": "^5.0.0", "@types/webpack-dev-server": "^3.11.4", - "@typescript-eslint/eslint-plugin": "^4.23.0", - "@typescript-eslint/parser": "^4.23.0", + "@typescript-eslint/eslint-plugin": "^5.6.0", + "@typescript-eslint/parser": "^5.6.0", "css-loader": "^5.2.4", - "eslint": "^7.26.0", - "fork-ts-checker-webpack-plugin": "^6.2.9", + "eslint": "^8.4.1", + "eslint-plugin-svelte3": "^3.2.1", + "fork-ts-checker-webpack-plugin": "^6.5.0", "html-webpack-plugin": "^5.3.1", "jasmine": "^3.5.0", "lint-staged": "^11.0.0", @@ -24,23 +25,24 @@ "node-polyfill-webpack-plugin": "^1.1.2", "npm-run-all": "^4.1.5", "prettier": "^2.3.1", + "prettier-plugin-svelte": "^2.5.0", "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", - "ts-node": "^9.1.1", + "ts-loader": "^9.2.6", + "ts-node": "^10.4.0", "tsconfig-paths": "^3.9.0", - "typescript": "^4.2.4", + "typescript": "^4.5.3", "webpack": "^5.37.0", "webpack-cli": "^4.7.0", "webpack-dev-server": "^3.11.2" }, "dependencies": { "@fontsource/press-start-2p": "^4.3.0", - "@joeattardi/emoji-button": "^4.6.0", + "@joeattardi/emoji-button": "^4.6.2", "@types/simple-peer": "^9.11.1", "@types/socket.io-client": "^1.4.32", "axios": "^0.21.2", @@ -55,6 +57,7 @@ "queue-typescript": "^1.0.1", "quill": "1.3.6", "quill-delta-to-html": "^0.12.0", + "retry-axios": "^2.6.0", "rxjs": "^6.6.3", "simple-peer": "^9.11.0", "socket.io-client": "^2.3.0", @@ -68,17 +71,21 @@ "build": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" NODE_ENV=production webpack", "build-typings": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack.json\" NODE_ENV=production BUILD_TYPINGS=1 webpack", "test": "cross-env 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", + "lint": "node_modules/.bin/eslint src/ tests/ --ext .ts,.svelte", + "fix": "node_modules/.bin/eslint --fix src/ tests/ --ext .ts,.svelte", "precommit": "lint-staged", "svelte-check-watch": "svelte-check --fail-on-warnings --fail-on-hints --compiler-warnings \"a11y-no-onchange:ignore,a11y-autofocus:ignore,a11y-media-has-caption:ignore\" --watch", "svelte-check": "svelte-check --fail-on-warnings --fail-on-hints --compiler-warnings \"a11y-no-onchange:ignore,a11y-autofocus:ignore,a11y-media-has-caption:ignore\"", - "pretty": "yarn prettier --write 'src/**/*.{ts,tsx}'", - "pretty-check": "yarn prettier --check 'src/**/*.{ts,tsx}'" + "pretty": "yarn prettier --write 'src/**/*.{ts,svelte}'", + "pretty-check": "yarn prettier --check 'src/**/*.{ts,svelte}'" }, "lint-staged": { - "*.ts": [ - "prettier --write" + "*.svelte": [ + "yarn run svelte-check" + ], + "*.{ts,svelte}": [ + "yarn run fix", + "yarn run pretty" ] } } diff --git a/front/src/Api/Events/ChangeZoneEvent.ts b/front/src/Api/Events/ChangeZoneEvent.ts new file mode 100644 index 00000000..e7ca3668 --- /dev/null +++ b/front/src/Api/Events/ChangeZoneEvent.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isChangeZoneEvent = new tg.IsInterface() + .withProperties({ + name: tg.isString, + }) + .get(); +/** + * A message sent from the game to the iFrame when a user enters or leaves a zone. + */ +export type ChangeZoneEvent = tg.GuardedType; diff --git a/front/src/Api/Events/ColorEvent.ts b/front/src/Api/Events/ColorEvent.ts new file mode 100644 index 00000000..c8e6d349 --- /dev/null +++ b/front/src/Api/Events/ColorEvent.ts @@ -0,0 +1,13 @@ +import * as tg from "generic-type-guard"; + +export const isColorEvent = new tg.IsInterface() + .withProperties({ + red: tg.isNumber, + green: tg.isNumber, + blue: tg.isNumber, + }) + .get(); +/** + * A message sent from the iFrame to the game to dynamically set the outline of the player. + */ +export type ColorEvent = tg.GuardedType; diff --git a/front/src/Api/Events/GameStateEvent.ts b/front/src/Api/Events/GameStateEvent.ts index 112c2880..1f0f36ed 100644 --- a/front/src/Api/Events/GameStateEvent.ts +++ b/front/src/Api/Events/GameStateEvent.ts @@ -9,6 +9,7 @@ export const isGameStateEvent = new tg.IsInterface() startLayerName: tg.isUnion(tg.isString, tg.isNull), tags: tg.isArray(tg.isString), variables: tg.isObject, + userRoomToken: tg.isUnion(tg.isString, tg.isUndefined), }) .get(); /** diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 081008c4..2871b93c 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -28,6 +28,8 @@ import type { MessageReferenceEvent } from "./ui/TriggerActionMessageEvent"; import { isMessageReferenceEvent, isTriggerActionMessageEvent } from "./ui/TriggerActionMessageEvent"; import type { MenuRegisterEvent, UnregisterMenuEvent } from "./ui/MenuRegisterEvent"; import type { ChangeLayerEvent } from "./ChangeLayerEvent"; +import type { ChangeZoneEvent } from "./ChangeZoneEvent"; +import { isColorEvent } from "./ColorEvent"; export interface TypedMessageEvent extends MessageEvent { data: T; @@ -76,6 +78,8 @@ export interface IframeResponseEventMap { leaveEvent: EnterLeaveEvent; enterLayerEvent: ChangeLayerEvent; leaveLayerEvent: ChangeLayerEvent; + enterZoneEvent: ChangeZoneEvent; + leaveZoneEvent: ChangeZoneEvent; buttonClickedEvent: ButtonClickedEvent; hasPlayerMoved: HasPlayerMovedEvent; menuItemClicked: MenuItemClickedEvent; @@ -149,6 +153,14 @@ export const iframeQueryMapTypeGuards = { query: isCreateEmbeddedWebsiteEvent, answer: tg.isUndefined, }, + setPlayerOutline: { + query: isColorEvent, + answer: tg.isUndefined, + }, + removePlayerOutline: { + query: tg.isUndefined, + answer: tg.isUndefined, + }, }; type GuardedType = T extends (x: unknown) => x is infer T ? T : never; diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index f30ce80c..67b49344 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -31,6 +31,7 @@ import type { SetVariableEvent } from "./Events/SetVariableEvent"; import { ModifyEmbeddedWebsiteEvent, isEmbeddedWebsiteEvent } from "./Events/EmbeddedWebsiteEvent"; import { handleMenuRegistrationEvent, handleMenuUnregisterEvent } from "../Stores/MenuStore"; import type { ChangeLayerEvent } from "./Events/ChangeLayerEvent"; +import type { ChangeZoneEvent } from "./Events/ChangeZoneEvent"; type AnswererCallback = ( query: IframeQueryMap[T]["query"], @@ -311,7 +312,7 @@ class IframeListener { "//" + window.location.host + '/iframe_api.js" >\n' + - '\n' + "\n" + @@ -414,6 +415,24 @@ class IframeListener { }); } + sendEnterZoneEvent(zoneName: string) { + this.postMessage({ + type: "enterZoneEvent", + data: { + name: zoneName, + } as ChangeZoneEvent, + }); + } + + sendLeaveZoneEvent(zoneName: string) { + this.postMessage({ + type: "leaveZoneEvent", + data: { + name: zoneName, + } as ChangeZoneEvent, + }); + } + hasPlayerMoved(event: HasPlayerMovedEvent) { if (this.sendPlayerMove) { this.postMessage({ diff --git a/front/src/Api/iframe/player.ts b/front/src/Api/iframe/player.ts index 078a1926..2d187bf5 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -1,4 +1,4 @@ -import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution"; +import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution"; import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent"; import { Subject } from "rxjs"; import { apiCallback } from "./registeredCallbacks"; @@ -20,6 +20,12 @@ export const setTags = (_tags: string[]) => { let uuid: string | undefined; +let userRoomToken: string | undefined; + +export const setUserRoomToken = (token: string | undefined) => { + userRoomToken = token; +}; + export const setUuid = (_uuid: string | undefined) => { uuid = _uuid; }; @@ -67,6 +73,33 @@ export class WorkadventurePlayerCommands extends IframeApiContribution { + return queryWorkadventure({ + type: "setPlayerOutline", + data: { + red, + green, + blue, + }, + }); + } + + public removeOutlineColor(): Promise { + return queryWorkadventure({ + type: "removePlayerOutline", + data: undefined, + }); + } } export default new WorkadventurePlayerCommands(); diff --git a/front/src/Components/App.svelte b/front/src/Components/App.svelte index ab8b6d3f..4886cc4e 100644 --- a/front/src/Components/App.svelte +++ b/front/src/Components/App.svelte @@ -1,147 +1,146 @@
{#if $loginSceneVisibleStore}
- +
{/if} {#if $selectCharacterSceneVisibleStore}
- +
{/if} {#if $customCharacterSceneVisibleStore}
- +
{/if} {#if $selectCompanionSceneVisibleStore}
- +
{/if} {#if $enableCameraSceneVisibilityStore}
- +
{/if} {#if $banMessageVisibleStore}
- +
{/if} {#if $textMessageVisibleStore}
- +
{/if} {#if $soundPlayingStore} -
- -
+
+ +
{/if} {#if $audioManagerVisibilityStore} -
- -
+
+ +
{/if} {#if $layoutManagerVisibilityStore}
- +
{/if} {#if $showReportScreenStore !== userReportEmpty}
- +
{/if} {#if $menuIconVisiblilityStore}
- +
{/if} {#if $menuVisiblilityStore}
- +
{/if} {#if $emoteMenuStore}
- +
{/if} {#if $gameOverlayVisibilityStore}
- - - + + +
{/if} {#if $helpCameraSettingsVisibleStore}
- +
{/if} {#if $requestVisitCardsStore} - + {/if} {#if $errorStore.length > 0} -
- -
+
+ +
{/if} {#if $chatVisibilityStore} - + {/if} {#if $warningContainerStore} - + {/if}
diff --git a/front/src/Components/AudioManager/AudioManager.svelte b/front/src/Components/AudioManager/AudioManager.svelte index e201254c..4422d002 100644 --- a/front/src/Components/AudioManager/AudioManager.svelte +++ b/front/src/Components/AudioManager/AudioManager.svelte @@ -1,10 +1,7 @@ -
- - - + + + + d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z" + /> + d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z" + /> + d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707z" + /> - +
+
- \ No newline at end of file + diff --git a/front/src/Components/CameraControls.svelte b/front/src/Components/CameraControls.svelte index 728c84e9..232d42da 100644 --- a/front/src/Components/CameraControls.svelte +++ b/front/src/Components/CameraControls.svelte @@ -1,6 +1,6 @@ - + - -