Merge branch 'develop' of github.com:thecodingmachine/workadventure into windows-focus-blur-camera

This commit is contained in:
David Négrier 2020-11-06 17:36:43 +01:00
commit 00b83ae349
22 changed files with 324 additions and 206 deletions

View File

@ -20,7 +20,7 @@ jobs:
# Create a slugified value of the branch # Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@master - uses: rlespinasse/github-slug-action@1.1.1
- name: "Build and push front image" - name: "Build and push front image"
uses: docker/build-push-action@v1 uses: docker/build-push-action@v1
@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
# Create a slugified value of the branch # Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@master - uses: rlespinasse/github-slug-action@1.1.1
- name: "Build and push back image" - name: "Build and push back image"
uses: docker/build-push-action@v1 uses: docker/build-push-action@v1
@ -66,7 +66,7 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
# Create a slugified value of the branch # Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@master - uses: rlespinasse/github-slug-action@1.1.1
- name: "Build and push back image" - name: "Build and push back image"
uses: docker/build-push-action@v1 uses: docker/build-push-action@v1
@ -90,7 +90,7 @@ jobs:
# Create a slugified value of the branch # Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@master - uses: rlespinasse/github-slug-action@1.1.1
- name: "Build and push front image" - name: "Build and push front image"
uses: docker/build-push-action@v1 uses: docker/build-push-action@v1
@ -137,7 +137,7 @@ jobs:
check_for_duplicate_msg: true check_for_duplicate_msg: true
- name: Run Cypress tests - name: Run Cypress tests
uses: cypress-io/github-action@v1 uses: cypress-io/github-action@v2
if: ${{ env.GITHUB_REF_SLUG != 'master' }} if: ${{ env.GITHUB_REF_SLUG != 'master' }}
env: env:
CYPRESS_BASE_URL: https://play.${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com CYPRESS_BASE_URL: https://play.${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com
@ -148,7 +148,7 @@ jobs:
working-directory: e2e working-directory: e2e
- name: Run Cypress tests in prod - name: Run Cypress tests in prod
uses: cypress-io/github-action@v1 uses: cypress-io/github-action@v2
if: ${{ env.GITHUB_REF_SLUG == 'master' }} if: ${{ env.GITHUB_REF_SLUG == 'master' }}
env: env:
CYPRESS_BASE_URL: https://play.workadventu.re CYPRESS_BASE_URL: https://play.workadventu.re

View File

@ -49,7 +49,7 @@
"multer": "^1.4.2", "multer": "^1.4.2",
"prom-client": "^12.0.0", "prom-client": "^12.0.0",
"query-string": "^6.13.3", "query-string": "^6.13.3",
"systeminformation": "^4.26.5", "systeminformation": "^4.27.11",
"ts-node-dev": "^1.0.0-pre.44", "ts-node-dev": "^1.0.0-pre.44",
"typescript": "^3.8.3", "typescript": "^3.8.3",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",

View File

@ -22,7 +22,7 @@ import {adminApi, CharacterTexture, FetchMemberDataByUuidResponse} from "../Serv
import {SocketManager, socketManager} from "../Services/SocketManager"; import {SocketManager, socketManager} from "../Services/SocketManager";
import {emitInBatch, resetPing} from "../Services/IoSocketHelpers"; import {emitInBatch, resetPing} from "../Services/IoSocketHelpers";
import {clientEventsEmitter} from "../Services/ClientEventsEmitter"; import {clientEventsEmitter} from "../Services/ClientEventsEmitter";
import {ADMIN_API_TOKEN} from "../Enum/EnvironmentVariable"; import {ADMIN_API_TOKEN, ADMIN_API_URL} from "../Enum/EnvironmentVariable";
export class IoSocketController { export class IoSocketController {
private nextUserId: number = 1; private nextUserId: number = 1;
@ -166,18 +166,21 @@ export class IoSocketController {
if(room.isFull){ if(room.isFull){
throw new Error('Room is full'); throw new Error('Room is full');
} }
try { if (ADMIN_API_URL) {
const userData = await adminApi.fetchMemberDataByUuid(userUuid); try {
//console.log('USERDATA', userData) const userData = await adminApi.fetchMemberDataByUuid(userUuid);
memberTags = userData.tags; //console.log('USERDATA', userData)
memberTextures = userData.textures; memberTags = userData.tags;
if (!room.anonymous && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && !room.canAccess(memberTags)) { memberTextures = userData.textures;
throw new Error('No correct tags') if (!room.anonymous && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && !room.canAccess(memberTags)) {
throw new Error('No correct tags')
}
//console.log('access granted for user '+userUuid+' and room '+roomId);
} catch (e) {
console.log('access not granted for user '+userUuid+' and room '+roomId);
console.error(e);
throw new Error('Client cannot acces this ressource.')
} }
//console.log('access granted for user '+userUuid+' and room '+roomId);
} catch (e) {
console.log('access not granted for user '+userUuid+' and room '+roomId);
throw new Error('Client cannot acces this ressource.')
} }
// Generate characterLayers objects from characterLayers string[] // Generate characterLayers objects from characterLayers string[]
@ -238,22 +241,24 @@ export class IoSocketController {
socketManager.handleJoinRoom(client); socketManager.handleJoinRoom(client);
resetPing(client); resetPing(client);
//get data information and shwo messages //get data information and show messages
adminApi.fetchMemberDataByUuid(client.userUuid).then((res: FetchMemberDataByUuidResponse) => { if (ADMIN_API_URL) {
if (!res.messages) { adminApi.fetchMemberDataByUuid(client.userUuid).then((res: FetchMemberDataByUuidResponse) => {
return; if (!res.messages) {
} return;
res.messages.forEach((c: unknown) => { }
const messageToSend = c as { type: string, message: string }; res.messages.forEach((c: unknown) => {
socketManager.emitSendUserMessage({ const messageToSend = c as { type: string, message: string };
userUuid: client.userUuid, socketManager.emitSendUserMessage({
type: messageToSend.type, userUuid: client.userUuid,
message: messageToSend.message type: messageToSend.type,
}) message: messageToSend.message
})
});
}).catch((err) => {
console.error('fetchMemberDataByUuid => err', err);
}); });
}).catch((err) => { }
console.error('fetchMemberDataByUuid => err', err);
});
}, },
message: (ws, arrayBuffer, isBinary): void => { message: (ws, arrayBuffer, isBinary): void => {
const client = ws as ExSocketInterface; const client = ws as ExSocketInterface;

View File

@ -1,5 +1,4 @@
import {App} from "../Server/sifrr.server"; import {App} from "../Server/sifrr.server";
import {IoSocketController} from "_Controller/IoSocketController";
import {HttpRequest, HttpResponse} from "uWebSockets.js"; import {HttpRequest, HttpResponse} from "uWebSockets.js";
const register = require('prom-client').register; const register = require('prom-client').register;
const collectDefaultMetrics = require('prom-client').collectDefaultMetrics; const collectDefaultMetrics = require('prom-client').collectDefaultMetrics;

View File

@ -3,7 +3,7 @@ const URL_ROOM_STARTED = "/Floor0/floor0.json";
const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64; const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64;
const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48; const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48;
const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false; const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false;
const ADMIN_API_URL = process.env.ADMIN_API_URL || 'http://admin'; const ADMIN_API_URL = process.env.ADMIN_API_URL || '';
const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || 'myapitoken'; const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || 'myapitoken';
const MAX_USERS_PER_ROOM = parseInt(process.env.MAX_USERS_PER_ROOM || '') || 600; const MAX_USERS_PER_ROOM = parseInt(process.env.MAX_USERS_PER_ROOM || '') || 600;
const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80; const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80;

View File

@ -152,7 +152,7 @@ export class GameRoom {
closestItem.join(user); closestItem.join(user);
} else { } else {
const closestUser : User = closestItem; const closestUser : User = closestItem;
const group: Group = new Group([ const group: Group = new Group(this.roomId,[
user, user,
closestUser closestUser
], this.connectCallback, this.disconnectCallback, this.positionNotifier); ], this.connectCallback, this.disconnectCallback, this.positionNotifier);
@ -200,7 +200,6 @@ export class GameRoom {
if (group === undefined) { if (group === undefined) {
throw new Error("The user is part of no group"); throw new Error("The user is part of no group");
} }
const oldPosition = group.getPosition();
group.leave(user); group.leave(user);
if (group.isEmpty()) { if (group.isEmpty()) {
this.positionNotifier.leave(group); this.positionNotifier.leave(group);
@ -209,6 +208,7 @@ export class GameRoom {
throw new Error("Could not find group "+group.getId()+" referenced by user "+user.id+" in World."); throw new Error("Could not find group "+group.getId()+" referenced by user "+user.id+" in World.");
} }
this.groups.delete(group); this.groups.delete(group);
//todo: is the group garbage collected?
} else { } else {
group.updatePosition(); group.updatePosition();
//this.positionNotifier.updatePosition(group, group.getPosition(), oldPosition); //this.positionNotifier.updatePosition(group, group.getPosition(), oldPosition);

View File

@ -3,6 +3,7 @@ import { User } from "./User";
import {PositionInterface} from "_Model/PositionInterface"; import {PositionInterface} from "_Model/PositionInterface";
import {Movable} from "_Model/Movable"; import {Movable} from "_Model/Movable";
import {PositionNotifier} from "_Model/PositionNotifier"; import {PositionNotifier} from "_Model/PositionNotifier";
import {gaugeManager} from "../Services/GaugeManager";
export class Group implements Movable { export class Group implements Movable {
static readonly MAX_PER_GROUP = 4; static readonly MAX_PER_GROUP = 4;
@ -13,12 +14,23 @@ export class Group implements Movable {
private users: Set<User>; private users: Set<User>;
private x!: number; private x!: number;
private y!: number; private y!: number;
private hasEditedGauge: boolean = false;
private wasDestroyed: boolean = false;
private roomId: string;
constructor(users: User[], private connectCallback: ConnectCallback, private disconnectCallback: DisconnectCallback, private positionNotifier: PositionNotifier) { constructor(roomId: string, users: User[], private connectCallback: ConnectCallback, private disconnectCallback: DisconnectCallback, private positionNotifier: PositionNotifier) {
this.roomId = roomId;
this.users = new Set<User>(); this.users = new Set<User>();
this.id = Group.nextId; this.id = Group.nextId;
Group.nextId++; Group.nextId++;
//we only send a event for prometheus metrics if the group lives more than 5 seconds
setTimeout(() => {
if (!this.wasDestroyed) {
this.hasEditedGauge = true;
gaugeManager.incNbGroupsPerRoomGauge(roomId);
}
}, 5000);
users.forEach((user: User) => { users.forEach((user: User) => {
this.join(user); this.join(user);
@ -113,9 +125,11 @@ export class Group implements Movable {
*/ */
destroy(): void destroy(): void
{ {
if (this.hasEditedGauge) gaugeManager.decNbGroupsPerRoomGauge(this.roomId);
for (const user of this.users) { for (const user of this.users) {
this.leave(user); this.leave(user);
} }
this.wasDestroyed = true;
} }
get getSize(){ get getSize(){

View File

@ -0,0 +1,54 @@
import {Counter, Gauge} from "prom-client";
//this class should manage all the custom metrics used by prometheus
class GaugeManager {
private nbClientsGauge: Gauge<string>;
private nbClientsPerRoomGauge: Gauge<string>;
private nbGroupsPerRoomGauge: Gauge<string>;
private nbGroupsPerRoomCounter: Counter<string>;
constructor() {
this.nbClientsGauge = new Gauge({
name: 'workadventure_nb_sockets',
help: 'Number of connected sockets',
labelNames: [ ]
});
this.nbClientsPerRoomGauge = new Gauge({
name: 'workadventure_nb_clients_per_room',
help: 'Number of clients per room',
labelNames: [ 'room' ]
});
this.nbGroupsPerRoomCounter = new Counter({
name: 'workadventure_counter_groups_per_room',
help: 'Counter of groups per room',
labelNames: [ 'room' ]
});
this.nbGroupsPerRoomGauge = new Gauge({
name: 'workadventure_nb_groups_per_room',
help: 'Number of groups per room',
labelNames: [ 'room' ]
});
}
incNbClientPerRoomGauge(roomId: string): void {
this.nbClientsGauge.inc();
this.nbClientsPerRoomGauge.inc({ room: roomId });
}
decNbClientPerRoomGauge(roomId: string): void {
this.nbClientsGauge.dec();
this.nbClientsPerRoomGauge.dec({ room: roomId });
}
incNbGroupsPerRoomGauge(roomId: string): void {
this.nbGroupsPerRoomCounter.inc({ room: roomId })
this.nbGroupsPerRoomGauge.inc({ room: roomId })
}
decNbGroupsPerRoomGauge(roomId: string): void {
this.nbGroupsPerRoomGauge.dec({ room: roomId })
}
}
export const gaugeManager = new GaugeManager();

View File

@ -1,4 +1,4 @@
import {ALLOW_ARTILLERY, SECRET_KEY} from "../Enum/EnvironmentVariable"; import {ADMIN_API_URL, ALLOW_ARTILLERY, SECRET_KEY} from "../Enum/EnvironmentVariable";
import {uuid} from "uuidv4"; import {uuid} from "uuidv4";
import Jwt from "jsonwebtoken"; import Jwt from "jsonwebtoken";
import {TokenInterface} from "../Controller/AuthenticateController"; import {TokenInterface} from "../Controller/AuthenticateController";
@ -48,17 +48,21 @@ class JWTTokenManager {
return; return;
} }
//verify user in admin if (ADMIN_API_URL) {
adminApi.fetchCheckUserByToken(tokenInterface.userUuid).then(() => { //verify user in admin
resolve(tokenInterface.userUuid); adminApi.fetchCheckUserByToken(tokenInterface.userUuid).then(() => {
}).catch((err) => {
//anonymous user
if(err.response && err.response.status && err.response.status === 404){
resolve(tokenInterface.userUuid); resolve(tokenInterface.userUuid);
return; }).catch((err) => {
} //anonymous user
reject(new Error('Authentication error, invalid token structure. ' + err)); if(err.response && err.response.status && err.response.status === 404){
}); resolve(tokenInterface.userUuid);
return;
}
reject(err);
});
} else {
resolve(tokenInterface.userUuid);
}
}); });
}); });
} }

View File

@ -23,7 +23,6 @@ import {
WebRtcStartMessage, WebRtcStartMessage,
QueryJitsiJwtMessage, QueryJitsiJwtMessage,
SendJitsiJwtMessage, SendJitsiJwtMessage,
CharacterLayerMessage,
SendUserMessage SendUserMessage
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import {PointInterface} from "../Model/Websocket/PointInterface"; import {PointInterface} from "../Model/Websocket/PointInterface";
@ -37,11 +36,11 @@ import {Movable} from "../Model/Movable";
import {PositionInterface} from "../Model/PositionInterface"; import {PositionInterface} from "../Model/PositionInterface";
import {adminApi, CharacterTexture} from "./AdminApi"; import {adminApi, CharacterTexture} from "./AdminApi";
import Direction = PositionMessage.Direction; import Direction = PositionMessage.Direction;
import {Gauge} from "prom-client";
import {emitError, emitInBatch} from "./IoSocketHelpers"; import {emitError, emitInBatch} from "./IoSocketHelpers";
import Jwt from "jsonwebtoken"; import Jwt from "jsonwebtoken";
import {JITSI_URL} from "../Enum/EnvironmentVariable"; import {JITSI_URL} from "../Enum/EnvironmentVariable";
import {clientEventsEmitter} from "./ClientEventsEmitter"; import {clientEventsEmitter} from "./ClientEventsEmitter";
import {gaugeManager} from "./GaugeManager";
interface AdminSocketRoomsList { interface AdminSocketRoomsList {
[index: string]: number; [index: string]: number;
@ -58,30 +57,13 @@ export interface AdminSocketData {
export class SocketManager { export class SocketManager {
private Worlds: Map<string, GameRoom> = new Map<string, GameRoom>(); private Worlds: Map<string, GameRoom> = new Map<string, GameRoom>();
private sockets: Map<number, ExSocketInterface> = new Map<number, ExSocketInterface>(); private sockets: Map<number, ExSocketInterface> = new Map<number, ExSocketInterface>();
private nbClientsGauge: Gauge<string>;
private nbClientsPerRoomGauge: Gauge<string>;
constructor() { constructor() {
this.nbClientsGauge = new Gauge({ clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => {
name: 'workadventure_nb_sockets', gaugeManager.incNbClientPerRoomGauge(roomId);
help: 'Number of connected sockets',
labelNames: [ ]
}); });
this.nbClientsPerRoomGauge = new Gauge({ clientEventsEmitter.registerToClientLeave((clientUUid: string, roomId: string) => {
name: 'workadventure_nb_clients_per_room', gaugeManager.decNbClientPerRoomGauge(roomId);
help: 'Number of clients per room',
labelNames: [ 'room' ]
});
clientEventsEmitter.registerToClientJoin((clientUUid, roomId) => {
this.nbClientsGauge.inc();
// Let's log server load when a user joins
console.log(new Date().toISOString() + ' A user joined (', this.sockets.size, ' connected users)');
});
clientEventsEmitter.registerToClientLeave((clientUUid, roomId) => {
this.nbClientsGauge.dec();
// Let's log server load when a user leaves
console.log('A user left (', this.sockets.size, ' connected users)');
}); });
} }
@ -107,7 +89,6 @@ export class SocketManager {
const viewport = client.viewport; const viewport = client.viewport;
try { try {
this.sockets.set(client.userId, client); //todo: should this be at the end of the function? this.sockets.set(client.userId, client); //todo: should this be at the end of the function?
clientEventsEmitter.emitClientJoin(client.userUuid, client.roomId);
//join new previous room //join new previous room
const gameRoom = this.joinRoom(client, position); const gameRoom = this.joinRoom(client, position);
@ -377,8 +358,8 @@ export class SocketManager {
} finally { } finally {
//delete Client.roomId; //delete Client.roomId;
this.sockets.delete(Client.userId); this.sockets.delete(Client.userId);
this.nbClientsPerRoomGauge.dec({ room: Client.roomId });
clientEventsEmitter.emitClientLeave(Client.userUuid, Client.roomId); clientEventsEmitter.emitClientLeave(Client.userUuid, Client.roomId);
console.log('A user left (', this.sockets.size, ' connected users)');
} }
} }
} }
@ -410,8 +391,6 @@ export class SocketManager {
private joinRoom(client : ExSocketInterface, position: PointInterface): GameRoom { private joinRoom(client : ExSocketInterface, position: PointInterface): GameRoom {
const roomId = client.roomId; const roomId = client.roomId;
//join user in room
this.nbClientsPerRoomGauge.inc({ room: roomId });
client.position = position; client.position = position;
const world = this.Worlds.get(roomId) const world = this.Worlds.get(roomId)
@ -425,6 +404,8 @@ export class SocketManager {
}); });
//join world //join world
world.join(client, client.position); world.join(client, client.position);
clientEventsEmitter.emitClientJoin(client.userUuid, client.roomId);
console.log(new Date().toISOString() + ' A user joined (', this.sockets.size, ' connected users)');
return world; return world;
} }

View File

@ -2345,10 +2345,10 @@ supports-color@^7.1.0:
dependencies: dependencies:
has-flag "^4.0.0" has-flag "^4.0.0"
systeminformation@^4.26.5: systeminformation@^4.27.11:
version "4.27.5" version "4.27.11"
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-4.27.5.tgz#af304fbfd0e7ba51c87512333691b58b4ad90e43" resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-4.27.11.tgz#6dbe96e48091444f80dab6c05ee1901286826b60"
integrity sha512-EysogxKqREk54ZYDEFcsCODv8GymKZcyiSfegYit8dKhPjzuQr+KX4GFHjssWjYrWFEIM2bYNsFrZX5eufeAXg== integrity sha512-U7bigXbOnsB8k1vNHS0Y13RCsRz5/UohiUmND+3mMUL6vfzrpbe/h4ZqewowB+B+tJNnmGFDj08Z8xGfYo45dQ==
table@^5.2.3: table@^5.2.3:
version "5.4.6" version "5.4.6"

View File

@ -3,6 +3,7 @@
local namespace = env.GITHUB_REF_SLUG, local namespace = env.GITHUB_REF_SLUG,
local tag = namespace, local tag = namespace,
local url = if namespace == "master" then "workadventu.re" else namespace+".workadventure.test.thecodingmachine.com", local url = if namespace == "master" then "workadventu.re" else namespace+".workadventure.test.thecodingmachine.com",
local adminUrl = if namespace == "master" || namespace == "develop" || std.startsWith(namespace, "admin") then "https://admin."+url else null,
"$schema": "https://raw.githubusercontent.com/thecodingmachine/deeployer/master/deeployer.schema.json", "$schema": "https://raw.githubusercontent.com/thecodingmachine/deeployer/master/deeployer.schema.json",
"version": "1.0", "version": "1.0",
"containers": { "containers": {
@ -16,11 +17,12 @@
"env": { "env": {
"SECRET_KEY": "tempSecretKeyNeedsToChange", "SECRET_KEY": "tempSecretKeyNeedsToChange",
"ADMIN_API_TOKEN": env.ADMIN_API_TOKEN, "ADMIN_API_TOKEN": env.ADMIN_API_TOKEN,
"ADMIN_API_URL": "https://admin."+url,
"JITSI_ISS": env.JITSI_ISS, "JITSI_ISS": env.JITSI_ISS,
"JITSI_URL": env.JITSI_URL, "JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
} } + if adminUrl != null then {
"ADMIN_API_URL": adminUrl,
} else {}
}, },
"front": { "front": {
"image": "thecodingmachine/workadventure-front:"+tag, "image": "thecodingmachine/workadventure-front:"+tag,

22
front/dist/index.html vendored
View File

@ -42,30 +42,14 @@
<body id="body" style="margin: 0"> <body id="body" style="margin: 0">
<div class="main-container" id="main-container"> <div class="main-container" id="main-container">
<!-- Create the editor container --> <!-- Create the editor container -->
<div id="game" class="game" style="/*background: red;*/"> <div id="game" class="game">
<div id="game-overlay" class="game-overlay" style="/*background: violet*/;"> <div id="game-overlay" class="game-overlay">
<div id="main-section" class="main-section"> <div id="main-section" class="main-section">
<!--<div style="background: lightpink;">a</div>
<div style="background: lightpink;">a</div> -->
</div> </div>
<aside id="sidebar" class="sidebar"> <aside id="sidebar" class="sidebar">
<!--<div style="background: lightgreen;">a</div>
<div style="background: green;">b</div>
<div style="background: darkgreen;">c</div>
<div style="background: darkgreen;">d</div>-->
</aside> </aside>
<div id="chat-mode" class="chat-mode three-col" style="display: none;"> <div id="chat-mode" class="chat-mode three-col" style="display: none;">
<!--<div style="background: lightgreen;">a</div>
<div style="background: green;">b</div>
<div style="background: darkgreen;">c</div>
<div style="background: darkolivegreen;">d</div>
<div style="background: darkolivegreen;">d</div>
<div style="background: darkgreen;">c</div>
<div style="background: green;">b</div>
<div style="background: lightgreen;">last elem for game</div>-->
</div> </div>
<div id="activeCam" class="activeCam"> <div id="activeCam" class="activeCam">
<div id="div-myCamVideo" class="video-container"> <div id="div-myCamVideo" class="video-container">
<video id="myCamVideo" autoplay muted></video> <video id="myCamVideo" autoplay muted></video>
@ -88,7 +72,7 @@
</div> </div>
</div> </div>
<div id="cowebsite" class="cowebsite"></div> <div id="cowebsite" class="cowebsite hidden"></div>
<div class="audio-playing"> <div class="audio-playing">
<img src="/resources/logos/megaphone.svg"/> <img src="/resources/logos/megaphone.svg"/>
</div> </div>

View File

@ -242,15 +242,10 @@ body {
.main-container { .main-container {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
display: flex; position: absolute;
align-items: stretch;
} }
@media (min-aspect-ratio: 1/1) { @media (min-aspect-ratio: 1/1) {
.main-container {
flex-direction: row;
}
.game-overlay { .game-overlay {
flex-direction: row; flex-direction: row;
} }
@ -266,12 +261,21 @@ body {
.sidebar > div:hover { .sidebar > div:hover {
max-height: 25%; max-height: 25%;
} }
#cowebsite {
right: 0;
top: 0;
width: 50%;
height: 100vh;
}
#cowebsite.loading {
transform: translateX(90%);
}
#cowebsite.hidden {
transform: translateX(100%);
}
} }
@media (max-aspect-ratio: 1/1) { @media (max-aspect-ratio: 1/1) {
.main-container {
flex-direction: column;
}
.game-overlay { .game-overlay {
flex-direction: column; flex-direction: column;
} }
@ -288,24 +292,36 @@ body {
.sidebar > div:hover { .sidebar > div:hover {
max-width: 25%; max-width: 25%;
} }
#cowebsite {
left: 0;
bottom: 0;
width: 100%;
height: 50%;
}
#cowebsite.loading {
transform: translateY(90%);
}
#cowebsite.hidden {
transform: translateY(100%);
}
} }
.game { #game {
flex-basis: 100%; width: 100%;
position: relative; /* Position relative is needed for the game-overlay. */ position: relative; /* Position relative is needed for the game-overlay. */
} }
/* A potentially shared website could appear in an iframe in the cowebsite space. */ /* A potentially shared website could appear in an iframe in the cowebsite space. */
.cowebsite { #cowebsite {
flex-basis: 100%; position: fixed;
transition: flex-basis 0.5s; transition: transform 0.5s;
}
#cowebsite.loading {
background-color: gray;
} }
/*.cowebsite:hover { #cowebsite > iframe {
flex-basis: 100%;
}*/
.cowebsite > iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }

View File

@ -38,7 +38,7 @@ import CanvasTexture = Phaser.Textures.CanvasTexture;
import GameObject = Phaser.GameObjects.GameObject; import GameObject = Phaser.GameObjects.GameObject;
import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR; import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR;
import {GameMap} from "./GameMap"; import {GameMap} from "./GameMap";
import {CoWebsiteManager} from "../../WebRtc/CoWebsiteManager"; import {coWebsiteManager} from "../../WebRtc/CoWebsiteManager";
import {mediaManager} from "../../WebRtc/MediaManager"; import {mediaManager} from "../../WebRtc/MediaManager";
import {FourOFourSceneName} from "../Reconnecting/FourOFourScene"; import {FourOFourSceneName} from "../Reconnecting/FourOFourScene";
import {ItemFactoryInterface} from "../Items/ItemFactoryInterface"; import {ItemFactoryInterface} from "../Items/ItemFactoryInterface";
@ -292,13 +292,6 @@ export class GameScene extends ResizableScene implements CenterListener {
// }); // });
// }); // });
} }
// TEST: let's load a module dynamically!
/*let foo = "http://maps.workadventure.localhost/computer.js";
import(/* webpackIgnore: true * / foo).then(result => {
console.log(result);
});*/
} }
//hook initialisation //hook initialisation
@ -476,9 +469,9 @@ export class GameScene extends ResizableScene implements CenterListener {
this.gameMap.onPropertyChange('openWebsite', (newValue, oldValue) => { this.gameMap.onPropertyChange('openWebsite', (newValue, oldValue) => {
if (newValue === undefined) { if (newValue === undefined) {
CoWebsiteManager.closeCoWebsite(); coWebsiteManager.closeCoWebsite();
} else { } else {
CoWebsiteManager.loadCoWebsite(newValue as string); coWebsiteManager.loadCoWebsite(newValue as string);
} }
}); });
this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue, allProps) => { this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue, allProps) => {

View File

@ -2,47 +2,90 @@ import {HtmlUtils} from "./HtmlUtils";
export type CoWebsiteStateChangedCallback = () => void; export type CoWebsiteStateChangedCallback = () => void;
export class CoWebsiteManager { enum iframeStates {
closed = 1,
loading, // loading an iframe can be slow, so we show some placeholder until it is ready
opened,
}
private static observers = new Array<CoWebsiteStateChangedCallback>(); const cowebsiteDivId = "cowebsite"; // the id of the parent div of the iframe.
const animationTime = 500; //time used by the css transitions, in ms.
public static loadCoWebsite(url: string): void { class CoWebsiteManager {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("cowebsite");
private opened: iframeStates = iframeStates.closed;
private observers = new Array<CoWebsiteStateChangedCallback>();
private close(): HTMLDivElement {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>(cowebsiteDivId);
cowebsiteDiv.classList.remove('loaded'); //edit the css class to trigger the transition
cowebsiteDiv.classList.add('hidden');
this.opened = iframeStates.closed;
return cowebsiteDiv;
}
private load(): HTMLDivElement {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>(cowebsiteDivId);
cowebsiteDiv.classList.remove('hidden'); //edit the css class to trigger the transition
cowebsiteDiv.classList.add('loading');
this.opened = iframeStates.loading;
return cowebsiteDiv;
}
private open(): HTMLDivElement {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>(cowebsiteDivId);
cowebsiteDiv.classList.remove('loading', 'hidden'); //edit the css class to trigger the transition
this.opened = iframeStates.opened;
return cowebsiteDiv;
}
public loadCoWebsite(url: string): void {
const cowebsiteDiv = this.load();
cowebsiteDiv.innerHTML = ''; cowebsiteDiv.innerHTML = '';
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.id = 'cowebsite-iframe'; iframe.id = 'cowebsite-iframe';
iframe.src = url; iframe.src = url;
const onloadPromise = new Promise((resolve) => {
iframe.onload = () => resolve();
});
cowebsiteDiv.appendChild(iframe); cowebsiteDiv.appendChild(iframe);
//iframe.onload = () => { const onTimeoutPromise = new Promise((resolve) => {
// onload can be long to trigger. Maybe we should display the website, whatever happens, after 1 second? setTimeout(() => resolve(), 2000);
CoWebsiteManager.fire(); });
//} Promise.race([onloadPromise, onTimeoutPromise]).then(() => {
this.open();
setTimeout(() => {
this.fire();
}, animationTime)
});
} }
/** /**
* Just like loadCoWebsite but the div can be filled by the user. * Just like loadCoWebsite but the div can be filled by the user.
*/ */
public static insertCoWebsite(callback: (cowebsite: HTMLDivElement) => void): void { public insertCoWebsite(callback: (cowebsite: HTMLDivElement) => Promise<void>): void {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("cowebsite"); const cowebsiteDiv = this.load();
cowebsiteDiv.innerHTML = ''; callback(cowebsiteDiv).then(() => {
this.open();
callback(cowebsiteDiv); setTimeout(() => {
//iframe.onload = () => { this.fire();
// onload can be long to trigger. Maybe we should display the website, whatever happens, after 1 second? }, animationTime)
CoWebsiteManager.fire(); });
//}
} }
public static closeCoWebsite(): void { public closeCoWebsite(): Promise<void> {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("cowebsite"); return new Promise((resolve, reject) => {
cowebsiteDiv.innerHTML = ''; const cowebsiteDiv = this.close();
CoWebsiteManager.fire(); this.fire();
setTimeout(() => {
resolve();
setTimeout(() => cowebsiteDiv.innerHTML = '', 500)
}, animationTime)
});
} }
public static getGameSize(): {width: number, height: number} { public getGameSize(): {width: number, height: number} {
const hasChildren = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("cowebsite").children.length > 0; if (this.opened !== iframeStates.opened) {
if (hasChildren === false) {
return { return {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight height: window.innerHeight
@ -61,13 +104,15 @@ export class CoWebsiteManager {
} }
} }
public static onStateChange(observer: CoWebsiteStateChangedCallback) { public onStateChange(observer: CoWebsiteStateChangedCallback) {
CoWebsiteManager.observers.push(observer); this.observers.push(observer);
} }
private static fire(): void { private fire(): void {
for (const callback of CoWebsiteManager.observers) { for (const callback of this.observers) {
callback(); callback();
} }
} }
} }
export const coWebsiteManager = new CoWebsiteManager();

View File

@ -1,6 +1,6 @@
import {CoWebsiteManager} from "./CoWebsiteManager";
import {JITSI_URL} from "../Enum/EnvironmentVariable"; import {JITSI_URL} from "../Enum/EnvironmentVariable";
import {mediaManager} from "./MediaManager"; import {mediaManager} from "./MediaManager";
import {coWebsiteManager} from "./CoWebsiteManager";
declare const window:any; // eslint-disable-line @typescript-eslint/no-explicit-any declare const window:any; // eslint-disable-line @typescript-eslint/no-explicit-any
const interfaceConfig = { const interfaceConfig = {
@ -31,9 +31,9 @@ class JitsiFactory {
private videoCallback = this.onVideoChange.bind(this); private videoCallback = this.onVideoChange.bind(this);
public start(roomName: string, playerName:string, jwt?: string): void { public start(roomName: string, playerName:string, jwt?: string): void {
CoWebsiteManager.insertCoWebsite((cowebsiteDiv => { coWebsiteManager.insertCoWebsite((cowebsiteDiv => {
const domain = JITSI_URL; const domain = JITSI_URL;
const options = { const options: any = { // eslint-disable-line @typescript-eslint/no-explicit-any
roomName: roomName, roomName: roomName,
jwt: jwt, jwt: jwt,
width: "100%", width: "100%",
@ -49,19 +49,23 @@ class JitsiFactory {
if (!options.jwt) { if (!options.jwt) {
delete options.jwt; delete options.jwt;
} }
this.jitsiApi = new window.JitsiMeetExternalAPI(domain, options);
this.jitsiApi.executeCommand('displayName', playerName);
this.jitsiApi.addListener('audioMuteStatusChanged', this.audioCallback); return new Promise((resolve) => {
this.jitsiApi.addListener('videoMuteStatusChanged', this.videoCallback); options.onload = () => resolve(); //we want for the iframe to be loaded before triggering animations.
this.jitsiApi = new window.JitsiMeetExternalAPI(domain, options);
this.jitsiApi.executeCommand('displayName', playerName);
this.jitsiApi.addListener('audioMuteStatusChanged', this.audioCallback);
this.jitsiApi.addListener('videoMuteStatusChanged', this.videoCallback);
});
})); }));
} }
public stop(): void { public async stop(): Promise<void> {
await coWebsiteManager.closeCoWebsite();
this.jitsiApi.removeListener('audioMuteStatusChanged', this.audioCallback); this.jitsiApi.removeListener('audioMuteStatusChanged', this.audioCallback);
this.jitsiApi.removeListener('videoMuteStatusChanged', this.videoCallback); this.jitsiApi.removeListener('videoMuteStatusChanged', this.videoCallback);
this.jitsiApi?.dispose(); this.jitsiApi?.dispose();
CoWebsiteManager.closeCoWebsite();
} }
private onAudioChange({muted}: {muted: boolean}): void { private onAudioChange({muted}: {muted: boolean}): void {

View File

@ -10,12 +10,9 @@ import {FourOFourScene} from "./Phaser/Reconnecting/FourOFourScene";
import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer; import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer;
import {OutlinePipeline} from "./Phaser/Shaders/OutlinePipeline"; import {OutlinePipeline} from "./Phaser/Shaders/OutlinePipeline";
import {CustomizeScene} from "./Phaser/Login/CustomizeScene"; import {CustomizeScene} from "./Phaser/Login/CustomizeScene";
import {CoWebsiteManager} from "./WebRtc/CoWebsiteManager";
import {gameManager} from "./Phaser/Game/GameManager";
import {ResizableScene} from "./Phaser/Login/ResizableScene"; import {ResizableScene} from "./Phaser/Login/ResizableScene";
import {EntryScene} from "./Phaser/Login/EntryScene"; import {EntryScene} from "./Phaser/Login/EntryScene";
import {coWebsiteManager} from "./WebRtc/CoWebsiteManager";
//CoWebsiteManager.loadCoWebsite('https://thecodingmachine.com');
// Load Jitsi if the environment variable is set. // Load Jitsi if the environment variable is set.
if (JITSI_URL) { if (JITSI_URL) {
@ -24,7 +21,7 @@ if (JITSI_URL) {
document.head.appendChild(jitsiScript); document.head.appendChild(jitsiScript);
} }
const {width, height} = CoWebsiteManager.getGameSize(); const {width, height} = coWebsiteManager.getGameSize();
const config: GameConfig = { const config: GameConfig = {
title: "WorkAdventure", title: "WorkAdventure",
@ -53,8 +50,7 @@ cypressAsserter.gameStarted();
const game = new Phaser.Game(config); const game = new Phaser.Game(config);
window.addEventListener('resize', function (event) { window.addEventListener('resize', function (event) {
const {width, height} = CoWebsiteManager.getGameSize(); const {width, height} = coWebsiteManager.getGameSize();
game.scale.resize(width / RESOLUTION, height / RESOLUTION); game.scale.resize(width / RESOLUTION, height / RESOLUTION);
// Let's trigger the onResize method of any active scene that is a ResizableScene // Let's trigger the onResize method of any active scene that is a ResizableScene
@ -64,8 +60,7 @@ window.addEventListener('resize', function (event) {
} }
} }
}); });
CoWebsiteManager.onStateChange(() => { coWebsiteManager.onStateChange(() => {
const {width, height} = CoWebsiteManager.getGameSize(); const {width, height} = coWebsiteManager.getGameSize();
game.scale.resize(width / RESOLUTION, height / RESOLUTION); game.scale.resize(width / RESOLUTION, height / RESOLUTION);
}); });

View File

@ -36,6 +36,15 @@
</head> </head>
<body class="choose-map"> <body class="choose-map">
<div class="container">
<div class="row">
<div class="col">
<a href="/" class="d-block mt-3 pixel-text">
&lt;&lt; BACK TO HOMEPAGE
</a>
</div>
</div>
</div>
<div class="container-fluid container-lg section pt-5"> <div class="container-fluid container-lg section pt-5">
<h1 class="text-center pixel-title">CHOOSE YOUR MAP&nbsp;!</h1> <h1 class="text-center pixel-title">CHOOSE YOUR MAP&nbsp;!</h1>
<div class="row"> <div class="row">

View File

@ -36,6 +36,15 @@
</head> </head>
<body class="create-map"> <body class="create-map">
<div class="container">
<div class="row">
<div class="col">
<a href="/" class="d-block mt-3 pixel-text">
&lt;&lt; BACK TO HOMEPAGE
</a>
</div>
</div>
</div>
<div class="container-fluid container-lg section pt-5"> <div class="container-fluid container-lg section pt-5">
<h1 class="text-center pixel-title">CREATE YOUR MAP&nbsp;!</h1> <h1 class="text-center pixel-title">CREATE YOUR MAP&nbsp;!</h1>
<div class="row"> <div class="row">
@ -44,7 +53,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
< class="col"> <div class="col">
<h2 id="tools-you-will-need" class="pixel-title">Tools you will need</h2> <h2 id="tools-you-will-need" class="pixel-title">Tools you will need</h2>
<p>In order to build your own map for WorkAdventure, you need:</p> <p>In order to build your own map for WorkAdventure, you need:</p>
<ul> <ul>

View File

@ -33,12 +33,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="title" content="Workadventure" /> <meta name="title" content="Workadventure" />
<meta name="description" content="You are impatient to discover this new world? Click on 'Play online' and meet new people or share this adventure with your colleagues and friends by clicking on 'Private mode'" /> <meta name="description" content="You are impatient to discover this new world? Click on 'Work online' and meet new people or share this adventure with your colleagues and friends by clicking on 'Work in private'" />
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:url" content="https://workadventu.re/" /> <meta property="og:url" content="https://workadventu.re/" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Workadventure" /> <meta property="og:title" content="Workadventure" />
<meta property="og:description" content="You are impatient to discover this new world? Click on 'Play online' and meet new people or share this adventure with your colleagues and friends by clicking on 'Private mode'" /> <meta property="og:description" content="You are impatient to discover this new world? Click on 'Work online' and meet new people or share this adventure with your colleagues and friends by clicking on 'Work in private'" />
<meta property="og:image" content="https://workadventu.re/static/images/meta-tags-image.jpg" /> <meta property="og:image" content="https://workadventu.re/static/images/meta-tags-image.jpg" />
<meta property="og:image:type" content="image/jpeg" /> <meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content="workadventure" /> <meta property="og:image:alt" content="workadventure" />
@ -46,7 +46,7 @@
<meta name="twitter:site" content="@coding_machine"> <meta name="twitter:site" content="@coding_machine">
<meta name="twitter:url" content="https://workadventu.re/" /> <meta name="twitter:url" content="https://workadventu.re/" />
<meta name="twitter:title" content="Workadventure" /> <meta name="twitter:title" content="Workadventure" />
<meta name="twitter:description" content="You are impatient to discover this new world? Click on 'Play online' and meet new people or share this adventure with your colleagues and friends by clicking on 'Private mode'" /> <meta name="twitter:description" content="You are impatient to discover this new world? Click on 'Work online' and meet new people or share this adventure with your colleagues and friends by clicking on 'Work in private'" />
<meta name="twitter:image" content="https://workadventu.re/static/images/meta-tags-image.jpg" /> <meta name="twitter:image" content="https://workadventu.re/static/images/meta-tags-image.jpg" />
<link rel="stylesheet" href="main.css"> <link rel="stylesheet" href="main.css">
<script src="bundle.js"></script> <script src="bundle.js"></script>
@ -97,17 +97,17 @@
</div> </div>
<div class="title title-main text-center"> <div class="title title-main text-center">
<h1>Your workplace<br/>but better</h1> <h1>Your workplace<br/>but better</h1>
<h3>You are impatient to discover this new world? Click on "Play online" and meet new people or share this adventure with your colleagues and friends by clicking on "Private mode"</h3> <h3>You are impatient to discover this new world? Click on "Work online" and meet new people or share this adventure with your colleagues and friends by clicking on "Work in private"</h3>
</div> </div>
<div class="row buttons-row justify-content-md-center pt-5"> <div class="row buttons-row justify-content-md-center pt-5">
<div class="col col-lg-3"> <div class="col col-lg-3">
<a class="custom-link start" href="/choose-map.html" title="PRIVATE MODE"> <a class="custom-link start" href="/choose-map.html" title="WORK IN PRIVATE">
PRIVATE MODE WORK IN PRIVATE
</a> </a>
</div> </div>
<div class="col col-lg-3"> <div class="col col-lg-3">
<a class="custom-link play" target="_BLANK" onclick="startGame()" title="PLAY ONLINE"> <a class="custom-link play" target="_BLANK" onclick="startGame()" title="WORK ONLINE">
PLAY ONLINE WORK ONLINE
</a> </a>
</div> </div>
</div> </div>
@ -165,10 +165,10 @@
<div class="bubble bubble-4 b-revert" style="height: 254px;"> <div class="bubble bubble-4 b-revert" style="height: 254px;">
<div> <div>
<p>You can also create a private room with your friends or your team ! </p> <p>You can also create a private room with your friends or your team ! </p>
<p class="bubble-legend">To try, press button private mode</p> <p class="bubble-legend">To try, press button work in private</p>
<p class="bubble-action"><a href="/choose-map.html"> <p class="bubble-action"><a href="/choose-map.html">
<img src="static/images/playicon.png" /> <img src="static/images/playicon.png" />
START IN PRIVATE MODE START WORKING IN PRIVATE
</a></p> </a></p>
<p> <p>
Dont forget to activate your mic and camera, lets play Dont forget to activate your mic and camera, lets play
@ -287,23 +287,23 @@
<div class="container-fluid container-lg"> <div class="container-fluid container-lg">
<div class="row justify-content-md-center"> <div class="row justify-content-md-center">
<div class="col-12 col-md-12 text-center"> <div class="col-12 col-md-12 text-center">
<h3>How to play</h3> <h3>HOW IT WORKS</h3>
</div> </div>
<div class="col-12 col-md-4 text-center my-3 my-md-0"> <div class="col-12 col-md-4 text-center my-3 my-md-0">
<div class="image-item"> <div class="image-item">
<h2>Choose your map</h2> <h2>CHOOSE YOUR WORKSPACE</h2>
<div class="step-image"><img src="static/images/maps/office.png"></div> <div class="step-image"><img src="static/images/maps/office.png"></div>
</div> </div>
</div> </div>
<div class="col-12 col-md-4 text-center my-3 my-md-0"> <div class="col-12 col-md-4 text-center my-3 my-md-0">
<div class="image-item"> <div class="image-item">
<h2>Select your character</h2> <h2>SELECT YOUR WOKA</h2>
<div class="step-image"><img src="static/images/choose_character.png"></div> <div class="step-image"><img src="static/images/choose_character.png"></div>
</div> </div>
</div> </div>
<div class="col-12 col-md-4 text-center my-3 my-md-0"> <div class="col-12 col-md-4 text-center my-3 my-md-0">
<div class="image-item"> <div class="image-item">
<h2>Let's go explore and talk&nbsp;!</h2> <h2>LET'S GO TO YOUR OFFICE</h2>
<div class="step-image"><img src="static/images/interact.png"></div> <div class="step-image"><img src="static/images/interact.png"></div>
</div> </div>
</div> </div>
@ -313,13 +313,14 @@
<div class="section bg-white"> <div class="section bg-white">
<div class="container-fluid container-lg"> <div class="container-fluid container-lg">
<div class="col-12 credits"> <div class="col-12 credits">
<h2>Future features</h2> <h2>CURRENT FEATURES</h2>
<h3>We have already thought of new features:</h3> <!-- <h3>We have already thought of new features:</h3>-->
<p>Share files with others</p> <p>Instant interaction with up to 4 people</p>
<p>Lock group conversations</p>
<p>Share screen with others</p> <p>Share screen with others</p>
<p>Interact with objects</p> <p>Organize a workshop and meeting with up to 60 people</p>
<h3>And you, what would you want?</h3> <p>Design your own digital space</p>
<p>... and infinite possibilities</p>
<h3>You need more? Do not hesitate to tell us!</h3>
<div class="row justify-content-md-center pt-5" style="margin-top: 65px;"> <div class="row justify-content-md-center pt-5" style="margin-top: 65px;">
<div class="col col-lg-3"> <div class="col col-lg-3">
<a class="custom-link contribute" target="_BLANK" href="https://docs.google.com/forms/d/e/1FAIpQLSdxvajEyqsn4X0ai0SoDAcdsa_JQPIfiP2Tp9PDzkfZA54v9Q/viewform" title="FEEDBACK"> <a class="custom-link contribute" target="_BLANK" href="https://docs.google.com/forms/d/e/1FAIpQLSdxvajEyqsn4X0ai0SoDAcdsa_JQPIfiP2Tp9PDzkfZA54v9Q/viewform" title="FEEDBACK">

View File

@ -148,7 +148,7 @@ header {
left: 0; left: 0;
right: 0; right: 0;
bottom: 7px; bottom: 7px;
padding: 1.125rem; padding: 1.125rem 0.5rem;
text-align: center; text-align: center;
z-index: 2; z-index: 2;
transition: all .1s cubic-bezier(0.000, -0.600, 1.000, 1.650); /* custom */ transition: all .1s cubic-bezier(0.000, -0.600, 1.000, 1.650); /* custom */
@ -431,7 +431,7 @@ img{
margin: 0; margin: 0;
min-height: 6rem; min-height: 6rem;
font-family: 'Karmatic Arcade'; font-family: 'Karmatic Arcade';
font-size: 22px; font-size: 24px;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
} }
@ -503,6 +503,9 @@ img{
.pixel-title{ .pixel-title{
font-family: "Karmatic Arcade" !important; font-family: "Karmatic Arcade" !important;
} }
.pixel-text{
font-family: "VCR OSD Mono" !important;
}
h3 { h3 {
font-weight: 800; font-weight: 800;