Merge remote-tracking branch 'official/master' into move-instance-in-game-2

This commit is contained in:
Davy Hélard
2025-07-17 10:37:52 +02:00
253 changed files with 4715 additions and 4195 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@
.Spotlight-V100
.Trashes
Thumbs.db
.claude

3
.vscode/tasks.json vendored
View File

@@ -38,8 +38,7 @@
"presentation": {
"reveal": "silent"
},
"isBackground": true,
"runOptions": { "instanceLimit": 1, "runOn": "folderOpen" }
"isBackground": true
},
{
"type": "npm",

View File

@@ -8,6 +8,8 @@
#include "GDCore/Serialization/SerializerElement.h"
namespace gd {
gd::String Effect::badStringParameterValue;
void Effect::SerializeTo(SerializerElement& element) const {
element.SetAttribute("name", GetName());

View File

@@ -34,39 +34,42 @@ class GD_CORE_API Effect {
void SetFolded(bool fold = true) { folded = fold; }
bool IsFolded() const { return folded; }
void SetDoubleParameter(const gd::String& name, double value) {
void SetDoubleParameter(const gd::String &name, double value) {
doubleParameters[name] = value;
}
double GetDoubleParameter(const gd::String& name) {
return doubleParameters[name];
double GetDoubleParameter(const gd::String &name) const {
auto itr = doubleParameters.find(name);
return itr == doubleParameters.end() ? 0 : itr->second;
}
bool HasDoubleParameter(const gd::String& name) {
bool HasDoubleParameter(const gd::String &name) const {
return doubleParameters.find(name) != doubleParameters.end();
}
void SetStringParameter(const gd::String& name, const gd::String& value) {
void SetStringParameter(const gd::String &name, const gd::String &value) {
stringParameters[name] = value;
}
const gd::String& GetStringParameter(const gd::String& name) {
return stringParameters[name];
const gd::String &GetStringParameter(const gd::String &name) const {
auto itr = stringParameters.find(name);
return itr == stringParameters.end() ? badStringParameterValue : itr->second;
}
bool HasStringParameter(const gd::String& name) {
bool HasStringParameter(const gd::String &name) const {
return stringParameters.find(name) != stringParameters.end();
}
void SetBooleanParameter(const gd::String& name, bool value) {
void SetBooleanParameter(const gd::String &name, bool value) {
booleanParameters[name] = value;
}
bool GetBooleanParameter(const gd::String& name) {
return booleanParameters[name];
bool GetBooleanParameter(const gd::String &name) const {
auto itr = booleanParameters.find(name);
return itr == booleanParameters.end() ? false : itr->second;
}
bool HasBooleanParameter(const gd::String& name) {
bool HasBooleanParameter(const gd::String &name) const {
return booleanParameters.find(name) != booleanParameters.end();
}
@@ -105,6 +108,9 @@ class GD_CORE_API Effect {
std::map<gd::String, double> doubleParameters; ///< Values of parameters being doubles, keyed by names.
std::map<gd::String, gd::String> stringParameters; ///< Values of parameters being strings, keyed by names.
std::map<gd::String, bool> booleanParameters; ///< Values of parameters being booleans, keyed by names.
static gd::String badStringParameterValue; ///< Empty string returned by
///< GeStringParameter
};
} // namespace gd

View File

@@ -5,8 +5,6 @@ namespace gdjs {
type Object3DNetworkSyncDataType = {
// z is position on the Z axis, different from zo, which is Z order
z: number;
w: number;
h: number;
d: number;
rx: number;
ry: number;
@@ -116,8 +114,6 @@ namespace gdjs {
return {
...super.getNetworkSyncData(),
z: this.getZ(),
w: this.getWidth(),
h: this.getHeight(),
d: this.getDepth(),
rx: this.getRotationX(),
ry: this.getRotationY(),
@@ -130,8 +126,6 @@ namespace gdjs {
updateFromNetworkSyncData(networkSyncData: Object3DNetworkSyncData) {
super.updateFromNetworkSyncData(networkSyncData);
if (networkSyncData.z !== undefined) this.setZ(networkSyncData.z);
if (networkSyncData.w !== undefined) this.setWidth(networkSyncData.w);
if (networkSyncData.h !== undefined) this.setHeight(networkSyncData.h);
if (networkSyncData.d !== undefined) this.setDepth(networkSyncData.d);
if (networkSyncData.rx !== undefined)
this.setRotationX(networkSyncData.rx);

View File

@@ -87,8 +87,8 @@ namespace gdjs {
this._boxMesh = boxMesh;
this._cube3DRuntimeObject = runtimeObject;
boxMesh.receiveShadow = this._cube3DRuntimeObject._isCastingShadow;
boxMesh.castShadow = this._cube3DRuntimeObject._isReceivingShadow;
boxMesh.receiveShadow = this._cube3DRuntimeObject._isReceivingShadow;
boxMesh.castShadow = this._cube3DRuntimeObject._isCastingShadow;
this.updateSize();
this.updatePosition();
this.updateRotation();

View File

@@ -1,4 +1,12 @@
namespace gdjs {
type CustomObject3DNetworkSyncDataType = CustomObjectNetworkSyncDataType & {
z: float;
d: float;
rx: float;
ry: float;
ifz: boolean;
};
/**
* Base class for 3D custom objects.
*/
@@ -77,6 +85,30 @@ namespace gdjs {
}
}
getNetworkSyncData(): CustomObject3DNetworkSyncDataType {
return {
...super.getNetworkSyncData(),
z: this.getZ(),
d: this.getDepth(),
rx: this.getRotationX(),
ry: this.getRotationY(),
ifz: this.isFlippedZ(),
};
}
updateFromNetworkSyncData(
networkSyncData: CustomObject3DNetworkSyncDataType
): void {
super.updateFromNetworkSyncData(networkSyncData);
if (networkSyncData.z !== undefined) this.setZ(networkSyncData.z);
if (networkSyncData.d !== undefined) this.setDepth(networkSyncData.d);
if (networkSyncData.rx !== undefined)
this.setRotationX(networkSyncData.rx);
if (networkSyncData.ry !== undefined)
this.setRotationY(networkSyncData.ry);
if (networkSyncData.ifz !== undefined) this.flipZ(networkSyncData.ifz);
}
/**
* Set the object position on the Z axis.
*/

View File

@@ -18,19 +18,18 @@ namespace gdjs {
return new gdjs.PixiFiltersTools.EmptyFilter();
}
return new (class implements gdjs.PixiFiltersTools.Filter {
private _light: THREE.DirectionalLight;
private _isEnabled: boolean = false;
private _top: string = 'Y-';
private _top: string = 'Z+';
private _elevation: float = 45;
private _rotation: float = 0;
private _shadowMapDirty = true;
private _shadowMapSize: float = 1024;
private _minimumShadowBias: float = 0;
private _shadowCameraDirty = true;
private _distanceFromCamera: float = 1500;
private _frustumSize: float = 4000;
private _isEnabled: boolean = false;
private _light: THREE.DirectionalLight;
private _shadowMapDirty = true;
private _shadowCameraDirty = true;
private _shadowCameraHelper: THREE.CameraHelper | null;
constructor() {

View File

@@ -18,18 +18,15 @@ namespace gdjs {
return new gdjs.PixiFiltersTools.EmptyFilter();
}
return new (class implements gdjs.PixiFiltersTools.Filter {
light: THREE.HemisphereLight;
rotationObject: THREE.Group;
_top: string = 'Z+';
_elevation: float = 90;
_rotation: float = 0;
_isEnabled: boolean = false;
top: string = 'Y-';
elevation: float = 45;
rotation: float = 0;
_light: THREE.HemisphereLight;
constructor() {
this.light = new THREE.HemisphereLight();
this.light.position.set(1, 0, 0);
this.rotationObject = new THREE.Group();
this.rotationObject.add(this.light);
this._light = new THREE.HemisphereLight();
this.updateRotation();
}
@@ -54,7 +51,7 @@ namespace gdjs {
if (!scene) {
return false;
}
scene.add(this.rotationObject);
scene.add(this._light);
this._isEnabled = true;
return true;
}
@@ -66,96 +63,106 @@ namespace gdjs {
if (!scene) {
return false;
}
scene.remove(this.rotationObject);
scene.remove(this._light);
this._isEnabled = false;
return true;
}
updatePreRender(target: gdjs.EffectsTarget): any {}
updateDoubleParameter(parameterName: string, value: number): void {
if (parameterName === 'intensity') {
this.light.intensity = value;
this._light.intensity = value;
} else if (parameterName === 'elevation') {
this.elevation = value;
this._elevation = value;
this.updateRotation();
} else if (parameterName === 'rotation') {
this.rotation = value;
this._rotation = value;
this.updateRotation();
}
}
getDoubleParameter(parameterName: string): number {
if (parameterName === 'intensity') {
return this.light.intensity;
return this._light.intensity;
} else if (parameterName === 'elevation') {
return this.elevation;
return this._elevation;
} else if (parameterName === 'rotation') {
return this.rotation;
return this._rotation;
}
return 0;
}
updateStringParameter(parameterName: string, value: string): void {
if (parameterName === 'skyColor') {
this.light.color = new THREE.Color(
this._light.color = new THREE.Color(
gdjs.rgbOrHexStringToNumber(value)
);
}
if (parameterName === 'groundColor') {
this.light.groundColor = new THREE.Color(
this._light.groundColor = new THREE.Color(
gdjs.rgbOrHexStringToNumber(value)
);
}
if (parameterName === 'top') {
this.top = value;
this._top = value;
this.updateRotation();
}
}
updateColorParameter(parameterName: string, value: number): void {
if (parameterName === 'skyColor') {
this.light.color.setHex(value);
this._light.color.setHex(value);
}
if (parameterName === 'groundColor') {
this.light.groundColor.setHex(value);
this._light.groundColor.setHex(value);
}
}
getColorParameter(parameterName: string): number {
if (parameterName === 'skyColor') {
return this.light.color.getHex();
return this._light.color.getHex();
}
if (parameterName === 'groundColor') {
return this.light.groundColor.getHex();
return this._light.groundColor.getHex();
}
return 0;
}
updateBooleanParameter(parameterName: string, value: boolean): void {}
updateRotation() {
if (this.top === 'Z+') {
// 0° is a light from the right of the screen.
this.rotationObject.rotation.z = gdjs.toRad(this.rotation);
this.rotationObject.rotation.y = -gdjs.toRad(this.elevation);
if (this._top === 'Y-') {
// `rotation` at 0° becomes a light from Z+.
this._light.position.set(
Math.cos(gdjs.toRad(-this._rotation + 90)) *
Math.cos(gdjs.toRad(this._elevation)),
-Math.sin(gdjs.toRad(this._elevation)),
Math.sin(gdjs.toRad(-this._rotation + 90)) *
Math.cos(gdjs.toRad(this._elevation))
);
} else {
// 0° becomes a light from Z+.
this.rotationObject.rotation.y = gdjs.toRad(this.rotation - 90);
this.rotationObject.rotation.z = -gdjs.toRad(this.elevation);
// `rotation` at 0° is a light from the right of the screen.
this._light.position.set(
Math.cos(gdjs.toRad(this._rotation)) *
Math.cos(gdjs.toRad(this._elevation)),
Math.sin(gdjs.toRad(this._rotation)) *
Math.cos(gdjs.toRad(this._elevation)),
Math.sin(gdjs.toRad(this._elevation))
);
}
}
getNetworkSyncData(): HemisphereLightFilterNetworkSyncData {
return {
i: this.light.intensity,
sc: this.light.color.getHex(),
gc: this.light.groundColor.getHex(),
e: this.elevation,
r: this.rotation,
t: this.top,
i: this._light.intensity,
sc: this._light.color.getHex(),
gc: this._light.groundColor.getHex(),
e: this._elevation,
r: this._rotation,
t: this._top,
};
}
updateFromNetworkSyncData(
syncData: HemisphereLightFilterNetworkSyncData
): void {
this.light.intensity = syncData.i;
this.light.color.setHex(syncData.sc);
this.light.groundColor.setHex(syncData.gc);
this.elevation = syncData.e;
this.rotation = syncData.r;
this.top = syncData.t;
this._light.intensity = syncData.i;
this._light.color.setHex(syncData.sc);
this._light.groundColor.setHex(syncData.gc);
this._elevation = syncData.e;
this._rotation = syncData.r;
this._top = syncData.t;
this.updateRotation();
}
})();

View File

@@ -1918,11 +1918,11 @@ module.exports = {
.setType('number');
properties
.getOrCreate('top')
.setValue('Y-')
.setValue('Z+')
.setLabel(_('3D world top'))
.setType('choice')
.addExtraInfo('Y-')
.addExtraInfo('Z+')
.addExtraInfo('Y-')
.setGroup(_('Orientation'));
properties
.getOrCreate('elevation')
@@ -1976,6 +1976,7 @@ module.exports = {
.setValue('1500')
.setLabel(_("Distance from layer's camera"))
.setType('number')
.setGroup(_('Shadows'))
.setAdvanced(true);
}
{
@@ -2008,11 +2009,11 @@ module.exports = {
.setType('number');
properties
.getOrCreate('top')
.setValue('Y-')
.setValue('Z+')
.setLabel(_('3D world top'))
.setType('choice')
.addExtraInfo('Y-')
.addExtraInfo('Z+')
.addExtraInfo('Y-')
.setGroup(_('Orientation'));
properties
.getOrCreate('elevation')

View File

@@ -31,6 +31,73 @@ module.exports = {
.addInstructionOrExpressionGroupMetadata(_('Multiplayer'))
.setIcon('JsPlatform/Extensions/multiplayer.svg');
extension
.addStrExpression(
'CurrentLobbyID',
_('Current lobby ID'),
_('Returns current lobby ID.'),
_('Lobbies'),
'JsPlatform/Extensions/multiplayer.svg'
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Multiplayer/peer.js')
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
.setFunctionName('gdjs.multiplayer.getLobbyID');
extension
.addAction(
'QuickJoinWithLobbyID',
_('Join a specific lobby by its ID'),
_(
'Join a specific lobby. The player will join the game instantly if this is possible.'
),
_('Join a specific lobby by its ID _PARAM1_'),
_('Lobbies'),
'JsPlatform/Extensions/multiplayer.svg',
'JsPlatform/Extensions/multiplayer.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('string', _('Lobby ID'), '', false)
.addParameter(
'yesorno',
_('Display loader while joining a lobby.'),
'',
true
)
.setDefaultValue('yes')
.addParameter(
'yesorno',
_('Display game lobbies if unable to join a specific one.'),
'',
true
)
.setDefaultValue('yes')
.setHelpPath('/all-features/multiplayer')
.getCodeExtraInformation()
.setIncludeFile('Extensions/Multiplayer/peer.js')
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
.setFunctionName('gdjs.multiplayer.authenticateAndQuickJoinWithLobbyID');
extension
.addAction(
'QuickJoinLobby',

View File

@@ -293,6 +293,8 @@ namespace gdjs {
x: objectNetworkSyncData.x,
y: objectNetworkSyncData.y,
z: objectNetworkSyncData.z,
w: objectNetworkSyncData.w,
h: objectNetworkSyncData.h,
zo: objectNetworkSyncData.zo,
a: objectNetworkSyncData.a,
hid: objectNetworkSyncData.hid,
@@ -369,6 +371,9 @@ namespace gdjs {
this._lastSentBasicObjectSyncData = {
x: objectNetworkSyncData.x,
y: objectNetworkSyncData.y,
z: objectNetworkSyncData.z,
w: objectNetworkSyncData.w,
h: objectNetworkSyncData.h,
zo: objectNetworkSyncData.zo,
a: objectNetworkSyncData.a,
hid: objectNetworkSyncData.hid,

View File

@@ -17,9 +17,29 @@ namespace gdjs {
}[];
};
type LobbyStatus =
| 'waiting'
| 'starting'
| 'playing'
| 'migrating'
| 'migrated';
type LobbyConnectionStatus = 'waiting' | 'ready' | 'connected';
type InGamePlayerStatus = 'playing' | 'left';
type PlayerStatus = LobbyConnectionStatus | InGamePlayerStatus;
type LobbyPlayer = {
playerId: string;
status: PlayerStatus;
playerNumber: number;
};
type Lobby = {
id: string;
status: 'waiting' | 'starting' | 'playing' | 'migrating' | 'migrated';
minPlayers: number;
maxPlayers: number;
canJoinAfterStart: boolean;
players: LobbyPlayer[];
status: LobbyStatus;
};
type QuickJoinLobbyResponse =
@@ -105,6 +125,7 @@ namespace gdjs {
let _quickJoinLobbyFailureReason:
| 'FULL'
| 'NOT_ENOUGH_PLAYERS'
| 'DOES_NOT_EXIST'
| 'UNKNOWN'
| null = null;
let _lobbyId: string | null = null;
@@ -1697,11 +1718,87 @@ namespace gdjs {
}
};
export const authenticateAndQuickJoinLobby = async (
export const getLobbyID = (): string => {
return _lobbyId || '';
};
const quickJoinWithLobbyID = async (
runtimeScene: gdjs.RuntimeScene,
lobbyID: string,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (_isQuickJoiningOrStartingAGame) return;
const _gameId = gdjs.projectData.properties.projectUuid;
if (!_gameId) {
logger.error(
'The game ID is missing, the quick join lobby action cannot continue.'
);
return;
}
_quickJoinLobbyFailureReason = null;
_isQuickJoiningOrStartingAGame = true;
if (displayLoader) {
gdjs.multiplayerComponents.displayLoader(runtimeScene, true);
}
const quickJoinWithLobbyIDRelativeUrl = `/play/game/${_gameId}/public-lobby/${lobbyID}`;
try {
const lobby: Lobby = await gdjs.evtTools.network.retryIfFailed(
{ times: 2 },
() =>
fetchAsPlayer({
relativeUrl: quickJoinWithLobbyIDRelativeUrl,
method: 'GET',
dev: isUsingGDevelopDevelopmentEnvironment,
})
);
const isFull = lobby.players.length === lobby.maxPlayers;
if (isFull) {
logger.error('Lobby is full - cannot quick join it.');
_quickJoinLobbyJustFailed = true;
_quickJoinLobbyFailureReason = 'FULL';
onLobbyQuickJoinFinished(runtimeScene);
if (openLobbiesPageIfFailure) {
openLobbiesWindow(runtimeScene);
}
return;
}
if (lobby.status === 'playing') {
_actionAfterJoiningLobby = 'JOIN_GAME';
} else if (lobby.status === 'waiting') {
if (lobby.players.length === 0) {
_actionAfterJoiningLobby = 'START_GAME';
} else {
_actionAfterJoiningLobby = 'OPEN_LOBBY_PAGE';
}
} else {
throw new Error(`Lobby in wrong status: ${lobby.status}`);
}
handleJoinLobbyEvent(runtimeScene, lobbyID);
} catch (error) {
const errorCode = parseInt(error.message.match(/\d{3}/)?.[0]);
if (errorCode === 404) {
logger.error('Lobby does not exist.');
_quickJoinLobbyFailureReason = 'DOES_NOT_EXIST';
} else {
logger.error('An error occurred while joining a lobby:', error);
_quickJoinLobbyFailureReason = 'UNKNOWN';
}
_quickJoinLobbyJustFailed = true;
onLobbyQuickJoinFinished(runtimeScene);
if (openLobbiesPageIfFailure) {
openLobbiesWindow(runtimeScene);
}
}
};
const isQuickJoiningTooFast = () => {
const requestDoneAt = Date.now();
if (_lastQuickJoinRequestDoneAt) {
if (requestDoneAt - _lastQuickJoinRequestDoneAt < 500) {
@@ -1709,12 +1806,18 @@ namespace gdjs {
logger.warn(
'Last request to quick join a lobby was sent too little time ago. Ignoring this one.'
);
return;
return true;
}
} else {
_lastQuickJoinRequestDoneAt = requestDoneAt;
}
return false;
};
const isNotCorrectlyAuthenticatedForQuickJoin = async (
runtimeScene: RuntimeScene
) => {
const playerId = gdjs.playerAuthentication.getUserId();
const playerToken = gdjs.playerAuthentication.getUserToken();
if (!playerId || !playerToken) {
@@ -1724,14 +1827,43 @@ namespace gdjs {
.promise;
_isWaitingForLogin = false;
if (status === 'logged') {
await quickJoinLobby(
runtimeScene,
displayLoader,
openLobbiesPageIfFailure
);
if (status !== 'logged') {
return true;
}
}
return false;
};
export const authenticateAndQuickJoinWithLobbyID = async (
runtimeScene: gdjs.RuntimeScene,
lobbyID: string,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (isQuickJoiningTooFast()) {
return;
}
if (await isNotCorrectlyAuthenticatedForQuickJoin(runtimeScene)) {
return;
}
await quickJoinWithLobbyID(
runtimeScene,
lobbyID,
displayLoader,
openLobbiesPageIfFailure
);
};
export const authenticateAndQuickJoinLobby = async (
runtimeScene: gdjs.RuntimeScene,
displayLoader: boolean,
openLobbiesPageIfFailure: boolean
) => {
if (isQuickJoiningTooFast()) {
return;
}
if (await isNotCorrectlyAuthenticatedForQuickJoin(runtimeScene)) {
return;
}
await quickJoinLobby(

View File

@@ -25,8 +25,6 @@ namespace gdjs {
export type PanelSpriteObjectData = ObjectData & PanelSpriteObjectDataType;
export type PanelSpriteNetworkSyncDataType = {
wid: number;
hei: number;
op: number;
color: string;
};
@@ -128,8 +126,6 @@ namespace gdjs {
getNetworkSyncData(): PanelSpriteNetworkSyncData {
return {
...super.getNetworkSyncData(),
wid: this.getWidth(),
hei: this.getHeight(),
op: this.getOpacity(),
color: this.getColor(),
};
@@ -142,12 +138,6 @@ namespace gdjs {
// Texture is not synchronized, see if this is asked or not.
if (networkSyncData.wid !== undefined) {
this.setWidth(networkSyncData.wid);
}
if (networkSyncData.hei !== undefined) {
this.setHeight(networkSyncData.hei);
}
if (networkSyncData.op !== undefined) {
this.setOpacity(networkSyncData.op);
}

View File

@@ -927,6 +927,54 @@ module.exports = {
.setFunctionName('setDensity')
.setGetter('getDensity');
aut
.addExpressionAndConditionAndAction(
'number',
'ShapeOffsetX',
_('Shape offset X'),
_('the object shape offset on X.'),
_('the shape offset on X'),
_('Body settings'),
'JsPlatform/Extensions/physics3d.svg'
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setShapeOffsetX')
.setGetter('getShapeOffsetX');
aut
.addExpressionAndConditionAndAction(
'number',
'ShapeOffsetY',
_('Shape offset Y'),
_('the object shape offset on Y.'),
_('the shape offset on Y'),
_('Body settings'),
'JsPlatform/Extensions/physics3d.svg'
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setShapeOffsetY')
.setGetter('getShapeOffsetY');
aut
.addExpressionAndConditionAndAction(
'number',
'ShapeOffsetZ',
_('Shape offset Z'),
_('the object shape offset on Z.'),
_('the shape offset on Z'),
_('Body settings'),
'JsPlatform/Extensions/physics3d.svg'
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setShapeOffsetZ')
.setGetter('getShapeOffsetZ');
aut
.addExpressionAndConditionAndAction(
'number',

View File

@@ -630,24 +630,34 @@ namespace gdjs {
override onDeActivate() {
this._sharedData.removeFromBehaviorsList(this);
this._destroyBody();
}
override onActivate() {
this._sharedData.addToBehaviorsList(this);
}
override onDestroy() {
this._destroyedDuringFrameLogic = true;
this.onDeActivate();
}
_destroyBody() {
this.bodyUpdater.destroyBody();
this._contactsEndedThisFrame.length = 0;
this._contactsStartedThisFrame.length = 0;
this._currentContacts.length = 0;
}
override onActivate() {
this._sharedData.addToBehaviorsList(this);
this._contactsEndedThisFrame.length = 0;
this._contactsStartedThisFrame.length = 0;
this._currentContacts.length = 0;
this.updateBodyFromObject();
resetToDefaultBodyUpdater() {
this.bodyUpdater = new gdjs.Physics3DRuntimeBehavior.DefaultBodyUpdater(
this
);
}
override onDestroy() {
this._destroyedDuringFrameLogic = true;
this.onDeActivate();
resetToDefaultCollisionChecker() {
this.collisionChecker =
new gdjs.Physics3DRuntimeBehavior.DefaultCollisionChecker(this);
}
createShape(): Jolt.Shape {
@@ -1176,6 +1186,33 @@ namespace gdjs {
this._needToRecreateBody = true;
}
getShapeOffsetX(): float {
return this.shapeOffsetX;
}
setShapeOffsetX(shapeOffsetX: float): void {
this.shapeOffsetX = shapeOffsetX;
this._needToRecreateShape = true;
}
getShapeOffsetY(): float {
return this.shapeOffsetY;
}
setShapeOffsetY(shapeOffsetY: float): void {
this.shapeOffsetY = shapeOffsetY;
this._needToRecreateShape = true;
}
getShapeOffsetZ(): float {
return this.shapeOffsetZ;
}
setShapeOffsetZ(shapeOffsetZ: float): void {
this.shapeOffsetZ = shapeOffsetZ;
this._needToRecreateShape = true;
}
getFriction(): float {
return this.friction;
}
@@ -1540,9 +1577,9 @@ namespace gdjs {
}
const body = this._body!;
const deltaX = towardX - body.GetPosition().GetX();
const deltaY = towardY - body.GetPosition().GetY();
const deltaZ = towardZ - body.GetPosition().GetZ();
const deltaX = towardX - this.owner3D.getX();
const deltaY = towardY - this.owner3D.getY();
const deltaZ = towardZ - this.owner3D.getZ();
const distanceSq = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ;
if (distanceSq === 0) {
return;
@@ -1600,19 +1637,16 @@ namespace gdjs {
length: float,
towardX: float,
towardY: float,
towardZ: float,
originX: float,
originY: float,
originZ: float
towardZ: float
): void {
if (this._body === null) {
if (!this._createBody()) return;
}
const body = this._body!;
const deltaX = towardX - originX;
const deltaY = towardY - originY;
const deltaZ = towardZ - originZ;
const deltaX = towardX - this.owner3D.getX();
const deltaY = towardY - this.owner3D.getY();
const deltaZ = towardZ - this.owner3D.getZ();
const distanceSq = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ;
if (distanceSq === 0) {
return;
@@ -1621,12 +1655,7 @@ namespace gdjs {
this._sharedData.bodyInterface.AddImpulse(
body.GetID(),
this.getVec3(deltaX * ratio, deltaY * ratio, deltaZ * ratio),
this.getRVec3(
originX * this._sharedData.worldInvScale,
originY * this._sharedData.worldInvScale,
originZ * this._sharedData.worldInvScale
)
this.getVec3(deltaX * ratio, deltaY * ratio, deltaZ * ratio)
);
}

View File

@@ -29,6 +29,7 @@ namespace gdjs {
owner3D: gdjs.RuntimeObject3D;
private _physics3DBehaviorName: string;
private _physics3D: Physics3D | null = null;
private _isHookedToPhysicsStep = false;
_vehicleController: Jolt.WheeledVehicleController | null = null;
_stepListener: Jolt.VehicleConstraintStepListener | null = null;
_vehicleCollisionTester: Jolt.VehicleCollisionTesterCastCylinder | null =
@@ -153,13 +154,19 @@ namespace gdjs {
const behavior = this.owner.getBehavior(
this._physics3DBehaviorName
) as gdjs.Physics3DRuntimeBehavior;
if (!behavior.activated()) {
return null;
}
const sharedData = behavior._sharedData;
this._physics3D = {
behavior,
};
sharedData.registerHook(this);
if (!this._isHookedToPhysicsStep) {
sharedData.registerHook(this);
this._isHookedToPhysicsStep = true;
}
behavior.bodyUpdater =
new gdjs.PhysicsCar3DRuntimeBehavior.VehicleBodyUpdater(
@@ -330,25 +337,33 @@ namespace gdjs {
}
override onDeActivate() {
if (this._stepListener) {
this._sharedData.physicsSystem.RemoveStepListener(this._stepListener);
if (!this._physics3D) {
return;
}
this._destroyBody();
}
override onActivate() {
if (this._stepListener) {
this._sharedData.physicsSystem.AddStepListener(this._stepListener);
const behavior = this.owner.getBehavior(
this._physics3DBehaviorName
) as gdjs.Physics3DRuntimeBehavior;
if (!behavior) {
return;
}
behavior._destroyBody();
}
override onDestroy() {
this._destroyedDuringFrameLogic = true;
this._destroyBody();
}
_destroyBody() {
if (!this._vehicleController) {
return;
}
this._destroyedDuringFrameLogic = true;
this.onDeActivate();
if (this._stepListener) {
// stepListener is removed by onDeActivate
this._sharedData.physicsSystem.RemoveStepListener(this._stepListener);
Jolt.destroy(this._stepListener);
this._stepListener = null;
}
@@ -360,6 +375,8 @@ namespace gdjs {
// It is destroyed with the constraint.
this._vehicleCollisionTester = null;
if (this._physics3D) {
const { behavior } = this._physics3D;
behavior.resetToDefaultBodyUpdater();
this._physics3D = null;
}
}
@@ -1110,7 +1127,7 @@ namespace gdjs {
}
destroyBody() {
this.carBehavior.onDestroy();
this.carBehavior._destroyBody();
this.physicsBodyUpdater.destroyBody();
}
}

View File

@@ -41,6 +41,7 @@ namespace gdjs {
owner3D: gdjs.RuntimeObject3D;
private _physics3DBehaviorName: string;
private _physics3D: Physics3D | null = null;
private _isHookedToPhysicsStep = false;
character: Jolt.CharacterVirtual | null = null;
/**
* sharedData is a reference to the shared data of the scene, that registers
@@ -169,10 +170,15 @@ namespace gdjs {
if (this._physics3D) {
return this._physics3D;
}
if (!this.activated()) {
return null;
}
const behavior = this.owner.getBehavior(
this._physics3DBehaviorName
) as gdjs.Physics3DRuntimeBehavior;
if (!behavior.activated()) {
return null;
}
const sharedData = behavior._sharedData;
const jolt = sharedData.jolt;
const extendedUpdateSettings = new Jolt.ExtendedUpdateSettings();
@@ -196,7 +202,10 @@ namespace gdjs {
shapeFilter,
};
this.setStairHeightMax(this._stairHeightMax);
sharedData.registerHook(this);
if (!this._isHookedToPhysicsStep) {
sharedData.registerHook(this);
this._isHookedToPhysicsStep = true;
}
behavior.bodyUpdater =
new gdjs.PhysicsCharacter3DRuntimeBehavior.CharacterBodyUpdater(this);
@@ -390,36 +399,48 @@ namespace gdjs {
}
override onDeActivate() {
this.collisionChecker.clearContacts();
if (!this._physics3D) {
return;
}
this._destroyBody();
}
override onActivate() {}
override onActivate() {
const behavior = this.owner.getBehavior(
this._physics3DBehaviorName
) as gdjs.Physics3DRuntimeBehavior;
if (!behavior) {
return;
}
behavior._destroyBody();
}
override onDestroy() {
this._destroyedDuringFrameLogic = true;
this.onDeActivate();
this._destroyCharacter();
}
/**
* Remove the character and its body from the physics engine.
* This method is called when:
* - The Physics3D behavior is deactivated
* - This behavior is deactivated
* - The object is destroyed
*
* Only deactivating the character behavior won't destroy the character.
* Indeed, deactivated characters don't move as characters but still have collisions.
*/
_destroyCharacter() {
_destroyBody() {
if (this.character) {
if (this._canBePushed) {
this.charactersManager.removeCharacter(this.character);
Jolt.destroy(this.character.GetListener());
}
this.collisionChecker.clearContacts();
// The body is destroyed with the character.
Jolt.destroy(this.character);
this.character = null;
if (this._physics3D) {
const { behavior } = this._physics3D;
behavior.resetToDefaultBodyUpdater();
behavior.resetToDefaultCollisionChecker();
this._physics3D.behavior._body = null;
const {
extendedUpdateSettings,
@@ -1780,7 +1801,7 @@ namespace gdjs {
}
destroyBody() {
this.characterBehavior._destroyCharacter();
this.characterBehavior._destroyBody();
}
}

View File

@@ -14,8 +14,6 @@ namespace gdjs {
export type SpineNetworkSyncDataType = {
opa: float;
wid: float;
hei: float;
scaX: float;
scaY: float;
flipX: boolean;
@@ -117,8 +115,6 @@ namespace gdjs {
return {
...super.getNetworkSyncData(),
opa: this._opacity,
wid: this.getWidth(),
hei: this.getHeight(),
scaX: this.getScaleX(),
scaY: this.getScaleY(),
flipX: this.isFlippedX(),
@@ -137,12 +133,6 @@ namespace gdjs {
if (syncData.opa !== undefined && syncData.opa !== this._opacity) {
this.setOpacity(syncData.opa);
}
if (syncData.wid !== undefined && syncData.wid !== this.getWidth()) {
this.setWidth(syncData.wid);
}
if (syncData.hei !== undefined && syncData.hei !== this.getHeight()) {
this.setHeight(syncData.hei);
}
if (syncData.scaX !== undefined && syncData.scaX !== this.getScaleX()) {
this.setScaleX(syncData.scaX);
}

View File

@@ -64,8 +64,6 @@ namespace gdjs {
export type TextInputNetworkSyncDataType = {
opa: float;
wid: float;
hei: float;
txt: string;
frn: string;
fs: number;
@@ -260,8 +258,6 @@ namespace gdjs {
return {
...super.getNetworkSyncData(),
opa: this.getOpacity(),
wid: this.getWidth(),
hei: this.getHeight(),
txt: this.getText(),
frn: this.getFontResourceName(),
fs: this.getFontSize(),
@@ -282,8 +278,6 @@ namespace gdjs {
super.updateFromNetworkSyncData(syncData);
if (syncData.opa !== undefined) this.setOpacity(syncData.opa);
if (syncData.wid !== undefined) this.setWidth(syncData.wid);
if (syncData.hei !== undefined) this.setHeight(syncData.hei);
if (syncData.txt !== undefined) this.setText(syncData.txt);
if (syncData.frn !== undefined) this.setFontResourceName(syncData.frn);
if (syncData.fs !== undefined) this.setFontSize(syncData.fs);

View File

@@ -17,8 +17,6 @@ namespace gdjs {
export type SimpleTileMapNetworkSyncDataType = {
op: number;
ai: string;
wid: number;
hei: number;
// TODO: Support tilemap synchronization. Find an efficient way to send tiles changes.
};
@@ -170,8 +168,6 @@ namespace gdjs {
...super.getNetworkSyncData(),
op: this._opacity,
ai: this._atlasImage,
wid: this.getWidth(),
hei: this.getHeight(),
};
}
@@ -186,18 +182,6 @@ namespace gdjs {
) {
this.setOpacity(networkSyncData.op);
}
if (
networkSyncData.wid !== undefined &&
networkSyncData.wid !== this.getWidth()
) {
this.setWidth(networkSyncData.wid);
}
if (
networkSyncData.hei !== undefined &&
networkSyncData.hei !== this.getHeight()
) {
this.setHeight(networkSyncData.hei);
}
if (networkSyncData.ai !== undefined) {
// TODO: support changing the atlas texture
}

View File

@@ -26,8 +26,6 @@ namespace gdjs {
os: float;
fo: float;
oo: float;
wid: float;
hei: float;
};
export type TilemapCollisionMaskNetworkSyncData = ObjectNetworkSyncData &
@@ -202,8 +200,6 @@ namespace gdjs {
os: this.getOutlineSize(),
fo: this.getFillOpacity(),
oo: this.getOutlineOpacity(),
wid: this.getWidth(),
hei: this.getHeight(),
};
}
@@ -236,12 +232,6 @@ namespace gdjs {
if (networkSyncData.oo !== undefined) {
this.setOutlineOpacity(networkSyncData.oo);
}
if (networkSyncData.wid !== undefined) {
this.setWidth(networkSyncData.wid);
}
if (networkSyncData.hei !== undefined) {
this.setHeight(networkSyncData.hei);
}
}
extraInitializationFromInitialInstance(initialInstanceData): void {

View File

@@ -25,8 +25,6 @@ namespace gdjs {
lai: number;
lei: number;
asps: number;
wid: number;
hei: number;
};
export type TilemapNetworkSyncData = ObjectNetworkSyncData &
@@ -158,8 +156,6 @@ namespace gdjs {
lai: this._layerIndex,
lei: this._levelIndex,
asps: this._animationSpeedScale,
wid: this.getWidth(),
hei: this.getHeight(),
};
}
@@ -190,12 +186,6 @@ namespace gdjs {
if (networkSyncData.asps !== undefined) {
this.setAnimationSpeedScale(networkSyncData.asps);
}
if (networkSyncData.wid !== undefined) {
this.setWidth(networkSyncData.wid);
}
if (networkSyncData.hei !== undefined) {
this.setHeight(networkSyncData.hei);
}
}
extraInitializationFromInitialInstance(initialInstanceData): void {

View File

@@ -15,8 +15,6 @@ namespace gdjs {
export type TiledSpriteObjectData = ObjectData & TiledSpriteObjectDataType;
export type TiledSpriteNetworkSyncDataType = {
wid: number;
hei: number;
xo: number;
yo: number;
op: number;
@@ -87,8 +85,6 @@ namespace gdjs {
getNetworkSyncData(): TiledSpriteNetworkSyncData {
return {
...super.getNetworkSyncData(),
wid: this.getWidth(),
hei: this.getHeight(),
xo: this.getXOffset(),
yo: this.getYOffset(),
op: this.getOpacity(),
@@ -103,12 +99,6 @@ namespace gdjs {
// Texture is not synchronized, see if this is asked or not.
if (networkSyncData.wid !== undefined) {
this.setWidth(networkSyncData.wid);
}
if (networkSyncData.hei !== undefined) {
this.setHeight(networkSyncData.hei);
}
if (networkSyncData.xo !== undefined) {
this.setXOffset(networkSyncData.xo);
}

View File

@@ -18,8 +18,6 @@ namespace gdjs {
export type VideoNetworkSyncDataType = {
op: float;
wid: float;
hei: float;
// We don't sync volume, as it's probably a user setting?
pla: boolean;
loop: boolean;
@@ -105,8 +103,6 @@ namespace gdjs {
return {
...super.getNetworkSyncData(),
op: this._opacity,
wid: this.getWidth(),
hei: this.getHeight(),
pla: this.isPlayed(),
loop: this.isLooped(),
ct: this.getCurrentTime(),
@@ -120,12 +116,6 @@ namespace gdjs {
if (this._opacity !== undefined && this._opacity && syncData.op) {
this.setOpacity(syncData.op);
}
if (this.getWidth() !== undefined && this.getWidth() !== syncData.wid) {
this.setWidth(syncData.wid);
}
if (this.getHeight() !== undefined && this.getHeight() !== syncData.hei) {
this.setHeight(syncData.hei);
}
if (syncData.pla !== undefined && this.isPlayed() !== syncData.pla) {
syncData.pla ? this.play() : this.pause();
}

View File

@@ -17,6 +17,11 @@ namespace gdjs {
isInnerAreaFollowingParentSize: boolean;
};
export type CustomObjectNetworkSyncDataType = ObjectNetworkSyncData & {
ifx: boolean;
ify: boolean;
};
/**
* An object that contains other object.
*
@@ -198,6 +203,26 @@ namespace gdjs {
return true;
}
getNetworkSyncData(): CustomObjectNetworkSyncDataType {
return {
...super.getNetworkSyncData(),
ifx: this.isFlippedX(),
ify: this.isFlippedY(),
};
}
updateFromNetworkSyncData(
networkSyncData: CustomObjectNetworkSyncDataType
) {
super.updateFromNetworkSyncData(networkSyncData);
if (networkSyncData.ifx !== undefined) {
this.flipX(networkSyncData.ifx);
}
if (networkSyncData.ify !== undefined) {
this.flipY(networkSyncData.ify);
}
}
override extraInitializationFromInitialInstance(
initialInstanceData: InstanceData
) {

View File

@@ -388,11 +388,9 @@ namespace gdjs {
): FloatPoint {
const position = result || [0, 0];
this._customObject.applyObjectTransformation(sceneX, sceneY, position);
return this._parent.convertInverseCoords(
position[0],
position[1],
position
);
return this._parent
.getLayer(this._customObject.getLayer())
.convertInverseCoords(position[0], position[1], 0, position);
}
/**

View File

@@ -380,11 +380,8 @@ namespace gdjs {
.isMouseInsideCanvas();
};
const _cursorIsOnObject = function (
obj: gdjs.RuntimeObject,
instanceContainer: gdjs.RuntimeInstanceContainer
) {
return obj.cursorOnObject(instanceContainer);
const _cursorIsOnObject = function (obj: gdjs.RuntimeObject) {
return obj.cursorOnObject();
};
export const cursorOnObject = function (
@@ -397,7 +394,7 @@ namespace gdjs {
_cursorIsOnObject,
objectsLists,
inverted,
instanceContainer
null
);
};

View File

@@ -484,6 +484,8 @@ namespace gdjs {
return {
x: this.x,
y: this.y,
w: this.getWidth(),
h: this.getHeight(),
zo: this.zOrder,
a: this.angle,
hid: this.hidden,
@@ -512,6 +514,12 @@ namespace gdjs {
if (networkSyncData.y !== undefined) {
this.setY(networkSyncData.y);
}
if (networkSyncData.w !== undefined) {
this.setWidth(networkSyncData.w);
}
if (networkSyncData.h !== undefined) {
this.setHeight(networkSyncData.h);
}
if (networkSyncData.zo !== undefined) {
this.setZOrder(networkSyncData.zo);
}
@@ -2737,11 +2745,12 @@ namespace gdjs {
*
* @return true if the cursor, or any touch, is on the object.
*/
cursorOnObject(instanceContainer: gdjs.RuntimeInstanceContainer): boolean {
cursorOnObject(): boolean {
const workingPoint: FloatPoint = gdjs.staticArray(
RuntimeObject.prototype.cursorOnObject
) as FloatPoint;
workingPoint.length = 2;
const instanceContainer = this.getInstanceContainer();
const inputManager = instanceContainer.getGame().getInputManager();
const layer = instanceContainer.getLayer(this.layer);
const mousePos = layer.convertCoords(

View File

@@ -53,6 +53,10 @@ declare type BasicObjectNetworkSyncData = {
y: number;
/** The position of the instance on the Z axis. Defined only for 3D games */
z?: number;
/** The width of the instance */
w: number;
/** The height of the instance */
h: number;
/** Z order of the instance */
zo: number;
/** The angle of the instance. */

View File

@@ -1,2 +1,3 @@
# Prettier has troubles handling the generic typing with flow in this file.
src/Utils/UseOptimisticState.js
src/Utils/UseOptimisticState.js
src/Utils/Sha256.js

View File

@@ -438,7 +438,6 @@ export const useGetConceptMetadata = () => {
}).current;
const getActionMetadata = useRefWithInit(() => {
return memoize((type: string) => {
console.log(type);
const {
extensionShortHeader,
eventsBasedBehavior,
@@ -447,7 +446,6 @@ export const useGetConceptMetadata = () => {
} = findEventsFunctionInExtensions(extensionShortHeadersByName, type);
if (extensionShortHeader && eventsFunction) {
console.log(eventsFunction);
return {
kind: 'Action',
name: eventsFunction.fullName,

View File

@@ -79,7 +79,8 @@ type Props = {
hasOpenedProject: boolean,
isAutoProcessingFunctionCalls: boolean,
setAutoProcessFunctionCalls: boolean => void,
onStartNewChat: () => void,
onStartNewChat: (mode: 'chat' | 'agent') => void,
initialMode?: 'chat' | 'agent',
onProcessFunctionCalls: (
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
@@ -136,12 +137,26 @@ const getQuotaOrCreditsText = ({
>
<div>
{isMobile ? (
<Trans>{quota.max - quota.current} free requests left</Trans>
increaseQuotaOffering === 'subscribe' ? (
<Trans>{quota.max - quota.current} trial requests left</Trans>
) : (
<Trans>{quota.max - quota.current} free requests left</Trans>
)
) : quota.period === '30days' ? (
increaseQuotaOffering === 'subscribe' ? (
<Trans>
{quota.max - quota.current} free trial requests left this month
</Trans>
) : (
<Trans>
{quota.max - quota.current} of {quota.max} free requests left this
month
</Trans>
)
) : quota.period === '1day' ? (
<Trans>{quota.max - quota.current} free requests left today</Trans>
) : (
<Trans>
{quota.max - quota.current} of {quota.max} free requests left this
month
</Trans>
<Trans>{quota.max - quota.current} free requests left</Trans>
)}
</div>
</Tooltip>
@@ -234,6 +249,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onSendMessage,
onSendFeedback,
onStartNewChat,
initialMode,
quota,
increaseQuotaOffering,
lastSendError,
@@ -252,7 +268,17 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
// TODO: store the default mode in the user preferences?
const [newAiRequestMode, setNewAiRequestMode] = React.useState<
'chat' | 'agent'
>('agent');
>(initialMode || 'agent');
// Update the mode when initialMode changes
React.useEffect(
() => {
if (initialMode) {
setNewAiRequestMode(initialMode);
}
},
[initialMode]
);
const aiRequestId: string = aiRequest ? aiRequest.id : '';
const [
userRequestTextPerAiRequestId,
@@ -619,7 +645,10 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
<Text size="body-small" color="secondary" align="center" noMargin>
<Trans>
This request is for another project.{' '}
<Link href="#" onClick={onStartNewChat}>
<Link
href="#"
onClick={() => onStartNewChat(aiRequest.mode || 'chat')}
>
Start a new chat
</Link>{' '}
to build on a new project.

View File

@@ -51,6 +51,7 @@ import {
} from '../Utils/Analytics/EventSender';
import { useCreateAiProjectDialog } from './UseCreateAiProjectDialog';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { setEditorHotReloadNeeded } from '../EmbeddedGame/EmbeddedGameFrame';
const gd: libGDevelop = global.gd;
@@ -483,6 +484,7 @@ type Props = {|
changes: SceneEventsOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
mode?: 'chat' | 'agent' | null,
gameEditorMode: 'embedded-game' | 'instances-editor',
|};
@@ -499,7 +501,7 @@ export type AskAiEditorInterface = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
startNewChat: () => void,
startNewChat: (mode: 'chat' | 'agent') => void,
forceInGameEditorHotReload: ({|
projectDataOnlyExport: boolean,
shouldReloadResources: boolean,
@@ -529,6 +531,7 @@ export const AskAiEditor = React.memo<Props>(
onOpenLayout,
onSceneEventsModifiedOutsideEditor,
onExtensionInstalled,
mode,
gameEditorMode,
}: Props,
ref
@@ -561,11 +564,27 @@ export const AskAiEditor = React.memo<Props>(
] = React.useState<NewAiRequestOptions | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
const [newChatMode, setNewChatMode] = React.useState<'chat' | 'agent'>(
mode || 'agent'
);
// Update newChatMode when mode prop changes
React.useEffect(
() => {
if (mode) {
setNewChatMode(mode);
}
},
[mode]
);
const canStartNewChat = !!selectedAiRequestId;
const onStartNewChat = React.useCallback(
() => {
(mode: 'chat' | 'agent') => {
setSelectedAiRequestId(null);
if (mode) {
setNewChatMode(mode);
}
},
[setSelectedAiRequestId]
);
@@ -743,11 +762,17 @@ export const AskAiEditor = React.memo<Props>(
setSendingAiRequest(null, true);
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
});
const aiRequest = await createAiRequest(getAuthorizationHeader, {
userRequest: userRequest,
userId: profile.id,
gameProjectJson: simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
...preparedAiUserContent,
payWithCredits,
gameId: project ? project.getProjectUuid() : null,
fileMetadata,
@@ -892,13 +917,19 @@ export const AskAiEditor = React.memo<Props>(
)
: null;
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
});
const aiRequest: AiRequest = await retryIfFailed({ times: 2 }, () =>
addMessageToAiRequest(getAuthorizationHeader, {
userId: profile.id,
aiRequestId: selectedAiRequestId,
functionCallOutputs,
gameProjectJson: simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
...preparedAiUserContent,
payWithCredits,
userMessage,
})
@@ -1046,6 +1077,7 @@ export const AskAiEditor = React.memo<Props>(
? isAutoProcessingFunctionCalls(selectedAiRequest.id)
: false
}
initialMode={newChatMode}
setAutoProcessFunctionCalls={shouldAutoProcess => {
if (!selectedAiRequest) return;
setAutoProcessFunctionCalls(
@@ -1104,6 +1136,7 @@ export const renderAskAiEditorContainer = (
props.onSceneEventsModifiedOutsideEditor
}
onExtensionInstalled={props.onExtensionInstalled}
mode={props.extraEditorProps && props.extraEditorProps.mode}
gameEditorMode={props.gameEditorMode}
/>
)}

View File

@@ -0,0 +1,233 @@
// @flow
import axios from 'axios';
import { retryIfFailed } from '../Utils/RetryIfFailed';
import {
createAiUserContentPresignedUrls,
type AiUserContentPresignedUrlsResult,
} from '../Utils/GDevelopServices/Generation';
import jsSHA from '../Utils/Sha256';
type UploadInfo = {
uploadedAt: number,
userRelativeKey: string | null,
};
const makeUploadCache = ({
minimalContentLength,
}: {|
minimalContentLength: number | null,
|}) => {
const uploadCacheByHash: {
[string]: UploadInfo,
} = {};
return {
getUserRelativeKey: (hash: string | null) => {
if (!hash) {
return null;
}
return (
(uploadCacheByHash[hash] && uploadCacheByHash[hash].userRelativeKey) ||
null
);
},
storeUpload: (hash: string | null, uploadInfo: UploadInfo) => {
if (!hash) return;
uploadCacheByHash[hash] = uploadInfo;
},
shouldUpload: ({
hash,
contentLength,
}: {|
hash: string | null,
contentLength: number,
|}) => {
if (!hash) {
// No hash, so no content to upload.
return false;
}
if (minimalContentLength && contentLength < minimalContentLength) {
// The content is too small to be uploaded.
return false;
}
if (
uploadCacheByHash[hash] &&
uploadCacheByHash[hash].uploadedAt > Date.now() - 1000 * 60 * 30
) {
// The content was already uploaded recently (and recently enough so that it has not expired in such a short time).
// We don't need to upload it again.
return false;
}
// The content was not uploaded, or not recently: we'll upload it now.
return true;
},
};
};
const projectSpecificExtensionsSummaryUploadCache = makeUploadCache({
minimalContentLength: null, // Always upload the project specific extensions summary.
});
const gameProjectJsonUploadCache = makeUploadCache({
minimalContentLength: 10 * 1000, // Roughly 10KB.
});
const computeSha256 = (payload: string): string => {
const shaObj = new jsSHA('SHA-256', 'TEXT', { encoding: 'UTF8' });
shaObj.update(payload);
return shaObj.getHash('HEX');
};
/**
* Prepare the user content to be used by the AI.
* It either uploads the content (and avoid uploading it again for a while)
* so that the request will just refer to the key where it's stored, or
* return the content so it's sent as part of the request itself (if it's small enough).
*/
export const prepareAiUserContent = async ({
getAuthorizationHeader,
userId,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
}: {|
getAuthorizationHeader: () => Promise<string>,
userId: string,
simplifiedProjectJson: string | null,
projectSpecificExtensionsSummaryJson: string | null,
|}) => {
// Hash the contents, if provided, to then upload it only once (as long as the hash stays
// the same, no need to re-upload it for a while).
// If the content is not provided, no hash is computed because there is no content to upload.
const startTime = Date.now();
const gameProjectJsonHash = simplifiedProjectJson
? computeSha256(simplifiedProjectJson)
: null;
const projectSpecificExtensionsSummaryJsonHash = projectSpecificExtensionsSummaryJson
? computeSha256(projectSpecificExtensionsSummaryJson)
: null;
const endTime = Date.now();
console.info(
`Hash of simplified project json and project specific extensions summary json took ${(
endTime - startTime
).toFixed(2)}ms`
);
const shouldUploadProjectSpecificExtensionsSummary = projectSpecificExtensionsSummaryUploadCache.shouldUpload(
{
hash: projectSpecificExtensionsSummaryJsonHash,
contentLength: projectSpecificExtensionsSummaryJson
? projectSpecificExtensionsSummaryJson.length
: 0,
}
);
const shouldUploadGameProjectJson = gameProjectJsonUploadCache.shouldUpload({
hash: gameProjectJsonHash,
contentLength: simplifiedProjectJson ? simplifiedProjectJson.length : 0,
});
if (
shouldUploadGameProjectJson ||
shouldUploadProjectSpecificExtensionsSummary
) {
const startTime = Date.now();
const {
gameProjectJsonSignedUrl,
gameProjectJsonUserRelativeKey,
projectSpecificExtensionsSummaryJsonSignedUrl,
projectSpecificExtensionsSummaryJsonUserRelativeKey,
}: AiUserContentPresignedUrlsResult = await retryIfFailed(
{ times: 3 },
() =>
createAiUserContentPresignedUrls(getAuthorizationHeader, {
userId,
gameProjectJsonHash: shouldUploadGameProjectJson
? gameProjectJsonHash
: null,
projectSpecificExtensionsSummaryJsonHash: shouldUploadProjectSpecificExtensionsSummary
? projectSpecificExtensionsSummaryJsonHash
: null,
})
);
const uploadedAt = Date.now();
await Promise.all([
gameProjectJsonSignedUrl
? retryIfFailed({ times: 3 }, () =>
axios.put(gameProjectJsonSignedUrl, simplifiedProjectJson, {
headers: {
'Content-Type': 'application/json',
},
// Allow any arbitrary large file to be sent
maxContentLength: Infinity,
})
).then(() => {
gameProjectJsonUploadCache.storeUpload(gameProjectJsonHash, {
uploadedAt,
userRelativeKey: gameProjectJsonUserRelativeKey || null,
});
})
: null,
projectSpecificExtensionsSummaryJsonSignedUrl
? retryIfFailed({ times: 3 }, () =>
axios.put(
projectSpecificExtensionsSummaryJsonSignedUrl,
projectSpecificExtensionsSummaryJson,
{
headers: {
'Content-Type': 'application/json',
},
// Allow any arbitrary large file to be sent
maxContentLength: Infinity,
}
)
).then(() => {
projectSpecificExtensionsSummaryUploadCache.storeUpload(
projectSpecificExtensionsSummaryJsonHash,
{
uploadedAt,
userRelativeKey:
projectSpecificExtensionsSummaryJsonUserRelativeKey || null,
}
);
})
: null,
]);
const endTime = Date.now();
console.info(
`Upload of ${[
shouldUploadGameProjectJson ? 'simplified project' : null,
shouldUploadProjectSpecificExtensionsSummary
? 'project specific extensions summary'
: null,
]
.filter(Boolean)
.join(' and ')} took ${(endTime - startTime).toFixed(2)}ms`
);
}
// Get the key at which the content was uploaded, if it was uploaded.
// If not, the content will be sent as part of the request instead of the upload key.
const gameProjectJsonUserRelativeKey = gameProjectJsonUploadCache.getUserRelativeKey(
gameProjectJsonHash
);
const projectSpecificExtensionsSummaryJsonUserRelativeKey = projectSpecificExtensionsSummaryUploadCache.getUserRelativeKey(
projectSpecificExtensionsSummaryJsonHash
);
return {
gameProjectJsonUserRelativeKey,
gameProjectJson: gameProjectJsonUserRelativeKey
? null
: simplifiedProjectJson,
projectSpecificExtensionsSummaryJsonUserRelativeKey,
projectSpecificExtensionsSummaryJson: projectSpecificExtensionsSummaryJsonUserRelativeKey
? null
: projectSpecificExtensionsSummaryJson,
};
};

View File

@@ -8,7 +8,7 @@ import RaisedButton from '../UI/RaisedButton';
import IconButton from '../UI/IconButton';
type Props = {|
onStartNewChat: () => void,
onStartNewChat: (mode: 'chat' | 'agent') => void,
canStartNewChat: boolean,
onOpenHistory: () => void,
|};
@@ -33,7 +33,7 @@ export const Toolbar = ({
<ToolbarGroup lastChild>
<RaisedButton
primary
onClick={onStartNewChat}
onClick={() => onStartNewChat('agent')}
icon={<AddIcon />}
label={<Trans>Start a new chat</Trans>}
disabled={!canStartNewChat}

View File

@@ -10,6 +10,7 @@ import {
import { type EventsGenerationResult } from '../EditorFunctions';
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
import { prepareAiUserContent } from './PrepareAiUserContent';
const gd: libGDevelop = global.gd;
@@ -40,21 +41,24 @@ export const useGenerateEvents = ({ project }: {| project: ?gdProject |}) => {
if (!profile) throw new Error('User should be authenticated.');
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
const simplifiedProjectJson = JSON.stringify(
simplifiedProjectBuilder.getSimplifiedProject(project, {})
);
const projectSpecificExtensionsSummaryJson = JSON.stringify(
simplifiedProjectBuilder.getProjectSpecificExtensionsSummary(project)
);
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
});
const createResult = await retryIfFailed({ times: 2 }, () =>
createAiGeneratedEvent(getAuthorizationHeader, {
userId: profile.id,
partialGameProjectJson: JSON.stringify(
simplifiedProjectBuilder.getSimplifiedProject(project, {
scopeToScene: sceneName,
}),
null,
2
),
projectSpecificExtensionsSummaryJson,
...preparedAiUserContent,
sceneName,
eventsDescription,
extensionNamesList,

View File

@@ -28,9 +28,10 @@ import {
import { useDebounce } from '../Utils/UseDebounce';
import PromotionsSlideshow from '../Promotions/PromotionsSlideshow';
import { ColumnStackLayout } from '../UI/Layout';
import { EarnCredits } from '../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
import { EarnCredits } from '../GameDashboard/Wallet/EarnCredits';
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
const cellSpacing = 2;
const cellSpacing = 10;
const getCategoryColumns = (
windowSize: WindowSizeType,
@@ -42,9 +43,9 @@ const getCategoryColumns = (
case 'medium':
return 3;
case 'large':
return 4;
return 5;
case 'xlarge':
return 6;
return 7;
default:
return 3;
}
@@ -58,11 +59,11 @@ const getShopItemsColumns = (
case 'small':
return isLandscape ? 3 : 2;
case 'medium':
return 2;
case 'large':
return 3;
case 'large':
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 2;
}
@@ -118,9 +119,13 @@ export const shopCategories = {
},
};
const MAX_COLUMNS = getShopItemsColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
grid: {
margin: '0 10px',
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
// Remove the scroll capability of the grid, the scroll view handles it.
overflow: 'unset',
},
@@ -378,7 +383,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
>
{openedShopCategory ? null : (
<>
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Explore by category</Trans>
@@ -395,8 +400,8 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</GridList>
</>
)}
{displayPromotions ? (
<ColumnStackLayout>
{displayPromotions && !openedShopCategory ? (
<ColumnStackLayout noMargin>
<Text size="block-title">
<Trans>Promotions + Earn credits</Trans>
</Text>
@@ -413,7 +418,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
) : null}
{allBundleTiles.length ? (
<>
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Bundles</Trans>
@@ -431,7 +436,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</>
) : null}
{openedShopCategoryTitle && (
<Column>
<Column noMargin>
<Line>
<Text size="block-title">{openedShopCategoryTitle}</Text>
</Line>
@@ -440,7 +445,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
{!hideGameTemplates && (
<>
{!openedShopCategoryTitle && (
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>All game templates</Trans>
@@ -459,7 +464,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</>
)}
{!openedShopCategoryTitle && (
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>All asset packs</Trans>

View File

@@ -79,7 +79,7 @@ const getShopItemsColumns = (
case 'medium':
return 2;
case 'large':
return 3;
return 4;
case 'xlarge':
return 5;
default:
@@ -138,7 +138,7 @@ export const getAssetShortHeadersToDisplay = (
return assetShortHeaders;
};
const cellSpacing = 8;
const cellSpacing = 10;
const styles = {
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
@@ -469,7 +469,6 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
// Don't show assets if filtering on asset packs.)
if (hasAssetPackFiltersApplied && !openedAssetPack) return [];
const assetSize = getAssetSize(windowSize);
const margin = cellSpacing / 2;
return getAssetShortHeadersToDisplay(
assetShortHeaders,
@@ -481,7 +480,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
onOpenDetails={() => onOpenDetails(assetShortHeader)}
size={assetSize}
key={assetShortHeader.id}
margin={margin}
margin={cellSpacing / 2}
hideShortDescription={!!hideDetails}
/>
));
@@ -749,7 +748,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{gameTemplateTiles}
</GridList>
@@ -765,7 +764,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{allBundlePackTiles}
</GridList>
@@ -781,7 +780,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{allStandAlonePackTiles}
</GridList>
@@ -945,12 +944,12 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
</Column>
) : null}
{openedAssetPack && folderTiles.length ? (
<Column justifyContent="center">
<Column justifyContent="center" noMargin>
<GridList
style={styles.grid}
cellHeight="auto"
cols={getAssetFoldersColumns(windowSize, isLandscape)}
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{folderTiles}
</GridList>

View File

@@ -22,10 +22,18 @@ import {
isLinkedToStartingPointExampleShortHeader,
isStartingPointExampleShortHeader,
} from '../../ProjectCreation/EmptyAndStartingPointProjects';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
const ITEMS_SPACING = 5;
const styles = {
grid: {
margin: 0,
textAlign: 'center',
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
// Remove the scroll capability of the grid, the scroll view handles it.
overflow: 'unset',
},
@@ -59,12 +67,13 @@ type Props = {|
i18n: I18nType,
onlyShowGames?: boolean,
hideStartingPoints?: boolean,
columnsCount: number,
rowToInsert?: {|
row: number,
element: React.Node,
|},
getColumnsFromWindowSize: (
windowSize: WindowSizeType,
isLandscape: boolean
) => number,
hideSearch?: boolean,
limitRowsTo?: number,
hidePremiumTemplates?: boolean,
|};
const ExampleStore = ({
@@ -73,10 +82,15 @@ const ExampleStore = ({
i18n,
onlyShowGames,
hideStartingPoints,
columnsCount,
rowToInsert,
getColumnsFromWindowSize,
hideSearch,
limitRowsTo,
hidePremiumTemplates,
}: Props) => {
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const { windowSize, isLandscape } = useResponsiveWindowSize();
const { receivedGameTemplates } = React.useContext(AuthenticatedUserContext);
const {
exampleShortHeaders: allExampleShortHeaders,
@@ -95,6 +109,8 @@ const ExampleStore = ({
const [localSearchText, setLocalSearchText] = React.useState(
exampleStoreSearchText
);
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const columnsCount = getColumnsFromWindowSize(windowSize, isLandscape);
const shouldAutofocusSearchbar = useShouldAutofocusInput();
const searchBarRef = React.useRef<?SearchBarInterface>(null);
@@ -143,14 +159,15 @@ const ExampleStore = ({
() => {
return getExampleAndTemplateTiles({
receivedGameTemplates,
privateGameTemplateListingDatas: privateGameTemplateListingDatasSearchResults
? privateGameTemplateListingDatasSearchResults
.map(({ item }) => item)
.filter(
privateGameTemplateListingData =>
!onlyShowGames || gameFilter(privateGameTemplateListingData)
)
: [],
privateGameTemplateListingDatas:
privateGameTemplateListingDatasSearchResults && !hidePremiumTemplates
? privateGameTemplateListingDatasSearchResults
.map(({ item }) => item)
.filter(
privateGameTemplateListingData =>
!onlyShowGames || gameFilter(privateGameTemplateListingData)
)
: [],
exampleShortHeaders: exampleShortHeadersSearchResults
? exampleShortHeadersSearchResults
.map(({ item }) => item)
@@ -183,9 +200,10 @@ const ExampleStore = ({
onSelectExampleShortHeader(exampleShortHeader);
},
i18n,
gdevelopTheme,
privateGameTemplatesPeriodicity: 1,
showOwnedGameTemplatesFirst: true,
}).allGridItems;
});
},
[
receivedGameTemplates,
@@ -194,55 +212,14 @@ const ExampleStore = ({
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
i18n,
gdevelopTheme,
onlyShowGames,
hideStartingPoints,
allExampleShortHeaders,
hidePremiumTemplates,
]
);
const nodesToDisplay: React.Node[] = React.useMemo(
() => {
const numberOfTilesToDisplayUntilRowToInsert = rowToInsert
? rowToInsert.row * columnsCount
: 0;
const firstTiles = resultTiles.slice(
0,
numberOfTilesToDisplayUntilRowToInsert
);
const lastTiles = resultTiles.slice(
numberOfTilesToDisplayUntilRowToInsert
);
return [
firstTiles.length > 0 ? (
<GridList
cols={columnsCount}
style={styles.grid}
cellHeight="auto"
spacing={2}
key="first-tiles"
>
{firstTiles}
</GridList>
) : null,
rowToInsert ? (
<Line key="inserted-row">{rowToInsert.element}</Line>
) : null,
lastTiles.length > 0 ? (
<GridList
cols={columnsCount}
style={styles.grid}
cellHeight="auto"
spacing={2}
key="last-tiles"
>
{lastTiles}
</GridList>
) : null,
].filter(Boolean);
},
[columnsCount, rowToInsert, resultTiles]
);
return (
<React.Fragment>
<Column expand noMargin>
@@ -267,11 +244,24 @@ const ExampleStore = ({
No results returned for your search. Try something else!
</Trans>
</BackgroundText>
{rowToInsert && <Line>{rowToInsert.element}</Line>}
</Column>
) : (
<ColumnStackLayout noMargin expand>
{nodesToDisplay}
<GridList
cols={columnsCount}
style={{
...styles.grid,
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
}}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{resultTiles.slice(
0,
limitRowsTo ? limitRowsTo * columnsCount : Infinity
)}
</GridList>
</ColumnStackLayout>
)}
</Column>

View File

@@ -64,8 +64,9 @@ import Window from '../../Utils/Window';
import RaisedButton from '../../UI/RaisedButton';
import PrivateAssetPackPurchaseDialog from './PrivateAssetPackPurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
const cellSpacing = 8;
const cellSpacing = 10;
const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
switch (windowSize) {
@@ -76,7 +77,7 @@ const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
case 'large':
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 3;
}
@@ -108,11 +109,16 @@ const contentTypeToMessageDescriptor: {
audio: t`audios`,
};
const MAX_COLUMNS = getPackColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 3,
@@ -211,10 +217,9 @@ const PrivateAssetPackInformationPage = ({
sellerPublicProfile,
setSellerPublicProfile,
] = React.useState<?UserPublicProfile>(null);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [displayPasswordPrompt, setDisplayPasswordPrompt] = React.useState<
'redeem' | 'credits' | null
>(null);
const [password, setPassword] = React.useState<string>('');
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const { isLandscape, isMediumScreen, windowSize } = useResponsiveWindowSize();
@@ -297,10 +302,16 @@ const PrivateAssetPackInformationPage = ({
const onWillRedeemAssetPack = () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
if (Window.isDev()) setDisplayPasswordPrompt('redeem');
else onRedeemAssetPack();
};
const onWillBuyWithCredits = () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt('credits');
else onClickBuyWithCredits();
};
const onRedeemAssetPack = React.useCallback(
async () => {
if (!profile || isRedeemingProduct) return;
@@ -704,7 +715,7 @@ const PrivateAssetPackInformationPage = ({
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={onClickBuyWithCredits}
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
</>
@@ -764,7 +775,7 @@ const PrivateAssetPackInformationPage = ({
<GridList
cols={getPackColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{packsIncludedInBundleTiles}
@@ -784,7 +795,7 @@ const PrivateAssetPackInformationPage = ({
<GridList
cols={getPackColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{otherPacksFromTheSameAuthorTiles}
@@ -797,8 +808,12 @@ const PrivateAssetPackInformationPage = ({
) : null}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onRedeemAssetPack}
onClose={() => setDisplayPasswordPrompt(false)}
onApply={
displayPasswordPrompt === 'redeem'
? onWillRedeemAssetPack
: onClickBuyWithCredits
}
onClose={() => setDisplayPasswordPrompt(null)}
passwordValue={password}
setPasswordValue={setPassword}
/>

View File

@@ -59,8 +59,9 @@ import Play from '../../UI/CustomSvgIcons/Play';
import PrivateGameTemplatePurchaseDialog from './PrivateGameTemplatePurchaseDialog';
import PasswordPromptDialog from '../PasswordPromptDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
const cellSpacing = 8;
const cellSpacing = 10;
const getTemplateColumns = (
windowSize: WindowSizeType,
@@ -74,17 +75,21 @@ const getTemplateColumns = (
case 'large':
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 3;
}
};
const MAX_COLUMNS = getTemplateColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 3,
@@ -558,7 +563,7 @@ const PrivateGameTemplateInformationPage = ({
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={onClickBuyWithCredits}
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
</>
@@ -605,7 +610,7 @@ const PrivateGameTemplateInformationPage = ({
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{templatesIncludedInBundleTiles}
@@ -625,7 +630,7 @@ const PrivateGameTemplateInformationPage = ({
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{otherTemplatesFromTheSameAuthorTiles}
@@ -638,7 +643,7 @@ const PrivateGameTemplateInformationPage = ({
) : null}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onWillBuyWithCredits}
onApply={onClickBuyWithCredits}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}

View File

@@ -4,6 +4,7 @@ import { type I18n as I18nType } from '@lingui/core';
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
type Purchase,
} from '../Utils/GDevelopServices/Shop';
import {
@@ -283,7 +284,10 @@ export const getUserProductPurchaseUsageType = <
};
export const PurchaseProductButtons = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
>({
productListingData,
selectedUsageType,
@@ -300,8 +304,8 @@ export const PurchaseProductButtons = <
simulateAppStoreProduct?: boolean,
i18n: I18nType,
isAlreadyReceived: boolean,
onClickBuy: () => Promise<void>,
onClickBuyWithCredits: () => Promise<void>,
onClickBuy: () => void | Promise<void>,
onClickBuyWithCredits: () => void | Promise<void>,
|}) => {
const { authenticated } = React.useContext(AuthenticatedUserContext);
const shouldUseOrSimulateAppStoreProduct =

View File

@@ -8,6 +8,7 @@ import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CreditsPackageListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import {
shouldUseAppStoreProduct,
@@ -16,11 +17,26 @@ import {
import Coin from '../Credits/Icons/Coin';
import { LineStackLayout } from '../UI/Layout';
import Text from '../UI/Text';
import { Column } from '../UI/Grid';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import type { GDevelopTheme } from '../UI/Theme';
const styles = {
icon: {
width: 12,
height: 12,
width: 13,
height: 13,
position: 'relative',
top: -1,
},
creditPriceContainer: {
display: 'flex',
alignItems: 'center',
gap: 3,
},
columnOrSeparator: {
marginTop: -3,
marginBottom: -1,
},
};
@@ -28,10 +44,12 @@ type FormatProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData,
i18n: I18nType,
usageType?: string,
plainText?: boolean,
showBothPrices?: 'column' | 'line', // If defined, will show both the credits price and the product price.
|};
export const renderProductPrice = ({
@@ -39,6 +57,7 @@ export const renderProductPrice = ({
productListingData,
usageType,
plainText,
showBothPrices,
}: FormatProps): React.Node => {
// Only use the app store product if it's a credits package.
if (
@@ -51,19 +70,21 @@ export const renderProductPrice = ({
return appStoreProduct ? appStoreProduct.price : '';
}
// If we're on mobile, only show credits prices for asset packs & game templates.
const creditPrices =
productListingData.productType !== 'CREDITS_PACKAGE'
? productListingData.creditPrices || []
: [];
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
// If we're on mobile, only show credits prices for non-credits packages.
if (
shouldUseAppStoreProduct() &&
productListingData.productType !== 'CREDITS_PACKAGE'
) {
const creditPrices = productListingData.creditPrices;
if (!creditPrices) return '';
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
if (!creditPrice) return '';
return plainText ? (
i18n._(t`${creditPrice.amount} credits`)
@@ -96,6 +117,40 @@ export const renderProductPrice = ({
return plainText ? (
formattedPrice
) : showBothPrices && creditPrice ? (
showBothPrices === 'column' ? (
<Column alignItems="flex-end">
<div style={styles.creditPriceContainer}>
<Coin style={styles.icon} />
<Text noMargin size="sub-title" color="inherit">
{creditPrice.amount}
</Text>
</div>
<span style={styles.columnOrSeparator}>
<Text noMargin color="inherit">
<Trans>or</Trans>
</Text>
</span>
<Text noMargin size="sub-title" color="primary">
{formattedPrice}
</Text>
</Column>
) : (
<LineStackLayout noMargin>
<div style={styles.creditPriceContainer}>
<Coin style={styles.icon} />
<Text noMargin size="sub-title" color="inherit">
{creditPrice.amount}
</Text>
</div>
<Text noMargin color="inherit">
<Trans>or</Trans>
</Text>
<Text noMargin size="sub-title" color="primary">
{formattedPrice}
</Text>
</LineStackLayout>
)
) : (
<Text noMargin size="sub-title" color="inherit">
{formattedPrice}
@@ -107,29 +162,36 @@ type ProductPriceOrOwnedProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData,
i18n: I18nType,
gdevelopTheme: GDevelopTheme,
usageType?: string,
owned?: boolean,
showBothPrices?: 'column' | 'line',
|};
export const getProductPriceOrOwnedLabel = ({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
<LineStackLayout noMargin alignItems="center">
<Text noMargin size="sub-title">
</Text>
<CheckCircle
style={{
color: gdevelopTheme.message.valid,
}}
/>
<Text noMargin size="sub-title" color="inherit">
<Trans>Owned</Trans>
</Text>
</LineStackLayout>
) : (
renderProductPrice({ i18n, productListingData, usageType })
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
);
};
@@ -137,7 +199,8 @@ type ProductPriceTagProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData,
usageType?: string,
/**
* To be used when the component is over an element for which
@@ -153,11 +216,13 @@ const ProductPriceTag = ({
withOverlay,
owned,
}: ProductPriceTagProps) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => {
const label = getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,

View File

@@ -12,9 +12,6 @@ import {
} from '../Utils/GDevelopServices/Shop';
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import GridListTile from '@material-ui/core/GridListTile';
import createStyles from '@material-ui/core/styles/createStyles';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { shouldValidate } from '../UI/KeyboardShortcuts/InteractionKeys';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import { textEllipsisStyle } from '../UI/TextEllipsis';
import { Column, Line, Spacer } from '../UI/Grid';
@@ -30,6 +27,7 @@ import { ResponsiveLineStackLayout } from '../UI/Layout';
import Skeleton from '@material-ui/lab/Skeleton';
import EmptyMessage from '../UI/EmptyMessage';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { CardWidget } from '../MainFrame/EditorContainers/HomePage/CardWidget';
const styles = {
priceTagContainer: {
@@ -81,11 +79,6 @@ const styles = {
minWidth: 200,
margin: 4,
},
paper: {
margin: 4,
display: 'flex',
flexDirection: 'column',
},
packTitle: {
...textEllipsisStyle,
overflowWrap: 'break-word',
@@ -112,20 +105,6 @@ const styles = {
},
};
const useStylesForGridListItem = ({ disabled }: {| disabled?: boolean |}) =>
makeStyles(theme =>
createStyles({
tile: !disabled
? {
transition: 'transform 0.3s ease-in-out',
'&:hover': {
transform: 'scale(1.02)',
},
}
: {},
})
)();
export const AssetCardTile = ({
assetShortHeader,
onOpenDetails,
@@ -141,30 +120,21 @@ export const AssetCardTile = ({
hideShortDescription?: boolean,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onOpenDetails();
}
}}
onClick={!disabled ? onOpenDetails : undefined}
style={{
margin,
}}
>
<AssetCard
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
assetShortHeader={assetShortHeader}
size={size}
hideShortDescription={hideShortDescription}
/>
<GridListTile style={{ margin }}>
<CardWidget
onClick={!disabled ? onOpenDetails : undefined}
size="large"
disabled={disabled}
noBorder
>
<AssetCard
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
assetShortHeader={assetShortHeader}
size={size}
hideShortDescription={hideShortDescription}
/>
</CardWidget>
</GridListTile>
);
};
@@ -181,29 +151,27 @@ export const AssetFolderTile = ({
style?: any,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<Column noMargin id={`asset-folder-${tag.replace(/\s/g, '-')}`}>
<Line alignItems="center">
<FolderIcon style={styles.folderIcon} />
<Text noMargin style={styles.folderTitle} size="sub-title">
{capitalize(tag)}
</Text>
</Line>
</Column>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column
noMargin
id={`asset-folder-${tag.replace(/\s/g, '-')}`}
noOverflowParent
>
<Line alignItems="center">
<FolderIcon style={styles.folderIcon} />
<Text noMargin style={styles.folderTitle} size="sub-title">
{capitalize(tag)}
</Text>
</Line>
</Column>
</CardWidget>
</GridListTile>
);
};
@@ -220,46 +188,46 @@ export const PublicAssetPackTile = ({
style?: any,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
style={styles.paper}
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<CorsAwareImage
key={assetPack.name}
style={styles.previewImage}
src={assetPack.thumbnailUrl}
alt={`Preview image of asset pack ${assetPack.name}`}
loading="lazy"
/>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPack.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
<Trans>{assetPack.assetsCount} Assets</Trans>
{assetPack.userFriendlyPrice
? ' - ' + assetPack.userFriendlyPrice
: null}
</Text>
</Line>
<Column
noMargin
expand
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
noOverflowParent
>
<CorsAwareImage
key={assetPack.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={assetPack.thumbnailUrl}
alt={`Preview image of asset pack ${assetPack.name}`}
loading="lazy"
/>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPack.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
<Trans>{assetPack.assetsCount} Assets</Trans>
{assetPack.userFriendlyPrice
? ' - ' + assetPack.userFriendlyPrice
: null}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -278,61 +246,59 @@ export const PrivateAssetPackTile = ({
owned: boolean,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div style={styles.paper}>
<div style={styles.thumbnailContainer}>
<CorsAwareImage
key={assetPackListingData.name}
style={styles.previewImage}
src={assetPackListingData.thumbnailUrls[0]}
alt={`Preview image of asset pack ${assetPackListingData.name}`}
loading="lazy"
/>
{assetPackListingData.redeemConditions && !owned && (
<div style={styles.redeemableContainer}>
<img
src="res/small-diamond.svg"
style={styles.redeemableDiamondIcon}
alt="diamond"
/>
<Text color="inherit" noMargin>
<Trans>Claim this pack</Trans>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<div style={styles.thumbnailContainer}>
<CorsAwareImage
key={assetPackListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={assetPackListingData.thumbnailUrls[0]}
alt={`Preview image of asset pack ${assetPackListingData.name}`}
loading="lazy"
/>
{assetPackListingData.redeemConditions && !owned && (
<div style={styles.redeemableContainer}>
<img
src="res/small-diamond.svg"
style={styles.redeemableDiamondIcon}
alt="diamond"
/>
<Text color="inherit" noMargin>
<Trans>Claim this pack</Trans>
</Text>
</div>
)}
</div>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={assetPackListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPackListingData.name}
</Text>
</div>
)}
</div>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={assetPackListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPackListingData.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
{assetPackListingData.description}
</Text>
</Line>
<Text style={styles.packTitle} color="primary" size="body2">
{assetPackListingData.description}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -455,40 +421,34 @@ export const CategoryTile = ({
style?: any,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div id={id} style={styles.paper}>
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.palette.primary,
}}
src={imageSource}
alt={imageAlt}
// No lazy loading because categories are the first seen tiles in the shop.
/>
<Column>
<Line justifyContent="center" noMargin>
<Text style={styles.packTitle} size="sub-title">
{title}
</Text>
</Line>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin alignItems="center" expand id={id} noOverflowParent>
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.palette.primary,
}}
src={imageSource}
alt={imageAlt}
// No lazy loading because categories are the first seen tiles in the shop.
/>
<Column>
<Line justifyContent="center" noMargin>
<Text style={styles.packTitle} size="sub-title">
{title}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -508,47 +468,45 @@ export const PrivateGameTemplateTile = ({
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div style={styles.paper}>
<CorsAwareImage
key={privateGameTemplateListingData.name}
style={styles.previewImage}
src={privateGameTemplateListingData.thumbnailUrls[0]}
alt={`Preview image of game template ${
privateGameTemplateListingData.name
}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={privateGameTemplateListingData}
withOverlay
owned={owned}
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={privateGameTemplateListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={privateGameTemplateListingData.thumbnailUrls[0]}
alt={`Preview image of game template ${
privateGameTemplateListingData.name
}`}
loading="lazy"
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{privateGameTemplateListingData.name}
</Text>
</Line>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={privateGameTemplateListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{privateGameTemplateListingData.name}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -572,6 +530,7 @@ export const ExampleTile = ({
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const thumbnailImgUrl = React.useMemo(
() => {
if (!exampleShortHeader) return '';
@@ -587,66 +546,68 @@ export const ExampleTile = ({
[exampleShortHeader, useQuickCustomizationThumbnail]
);
const classesForGridListItem = useStylesForGridListItem({ disabled });
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div style={styles.paper}>
{exampleShortHeader ? (
thumbnailImgUrl ? (
<CorsAwareImage
key={exampleShortHeader.name}
style={styles.previewImage}
src={thumbnailImgUrl}
alt={`Preview image of example ${exampleShortHeader.name}`}
loading="lazy"
/>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
{exampleShortHeader ? (
thumbnailImgUrl ? (
<CorsAwareImage
key={exampleShortHeader.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={thumbnailImgUrl}
alt={`Preview image of example ${exampleShortHeader.name}`}
loading="lazy"
/>
) : (
<EmptyMessage
style={{
...styles.previewImage,
display: 'flex',
}}
>
{exampleShortHeader.name}
</EmptyMessage>
)
) : (
<EmptyMessage
style={{
...styles.previewImage,
display: 'flex',
}}
<Skeleton
variant="rect"
width="100%"
height="100%"
style={styles.dataLoadingSkeleton}
/>
)}
<Column>
{isMobile && <Spacer />}
<Line
justifyContent={centerTitle ? 'center' : 'flex-start'}
noMargin
>
{exampleShortHeader.name}
</EmptyMessage>
)
) : (
<Skeleton
variant="rect"
width="100%"
height="100%"
style={styles.dataLoadingSkeleton}
/>
)}
<Column>
{isMobile && <Spacer />}
<Line justifyContent={centerTitle ? 'center' : 'flex-start'} noMargin>
<Text
style={styles.packTitle}
size="body2"
hidden={!exampleShortHeader}
noMargin={isMobile}
>
{customTitle
? customTitle
: exampleShortHeader
? exampleShortHeader.name
: // Use some placeholder text to avoid layout shift while loading content.
'Abcdef123'}
</Text>
</Line>
<Text
style={styles.packTitle}
size="body2"
hidden={!exampleShortHeader}
noMargin={isMobile}
>
{customTitle
? customTitle
: exampleShortHeader
? exampleShortHeader.name
: // Use some placeholder text to avoid layout shift while loading content.
'Abcdef123'}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};

View File

@@ -3,13 +3,15 @@
import * as React from 'react';
import { Trans } from '@lingui/macro';
import type { CourseChapter } from '../Utils/GDevelopServices/Asset';
import type { CourseChapter, Course } from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { LineStackLayout } from '../UI/Layout';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
import Chip from '../UI/Chip';
import { freeChipStyle } from './CoursePreviewBanner';
const styles = {
titleContainer: {
@@ -24,13 +26,14 @@ const styles = {
};
type Props = {|
course: Course,
chapterIndex: number,
courseChapter: CourseChapter,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|};
const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
({ chapterIndex, courseChapter, getChapterCompletion }, ref) => {
({ course, chapterIndex, courseChapter, getChapterCompletion }, ref) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { isMobile, isLandscape } = useResponsiveWindowSize();
const isMobilePortrait = isMobile && !isLandscape;
@@ -64,25 +67,30 @@ const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
</div>
)}
</LineStackLayout>
{isFinished ? (
<div
style={{
...styles.statusContainer,
color: gdevelopTheme.statusIndicator.success,
}}
>
{isMobilePortrait && <CheckCircle />}
<Text color="inherit" noMargin>
<Trans>Finished</Trans>
<LineStackLayout noMargin alignItems="center">
{course.isLocked && courseChapter.isFree && (
<Chip style={freeChipStyle} label={<Trans>Free!</Trans>} />
)}
{isFinished ? (
<div
style={{
...styles.statusContainer,
color: gdevelopTheme.statusIndicator.success,
}}
>
{isMobilePortrait && <CheckCircle />}
<Text color="inherit" noMargin>
<Trans>Finished</Trans>
</Text>
</div>
) : completion ? (
<Text color="secondary" noMargin>
<Trans>
{completion.completedTasks} of {completion.tasks} completed
</Trans>
</Text>
</div>
) : completion ? (
<Text color="secondary" noMargin>
<Trans>
{completion.completedTasks} of {completion.tasks} completed
</Trans>
</Text>
) : null}
) : null}
</LineStackLayout>
</div>
);
}

View File

@@ -34,6 +34,12 @@ import EmptyBadge from '../UI/CustomSvgIcons/EmptyBadge';
import Skeleton from '@material-ui/lab/Skeleton';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
export const freeChipStyle = {
height: 20,
backgroundColor: '#8BE7C4',
color: '#1D1D26',
};
const styles = {
container: { padding: 16, display: 'flex', borderRadius: 8 },
mobileContainer: { padding: 8, display: 'flex', borderRadius: 8 },
@@ -48,16 +54,6 @@ const styles = {
},
progress: { borderRadius: 4, height: 5 },
chip: { height: 24 },
freeChip: {
height: 20,
borderRadius: 32,
paddingTop: 2,
paddingBottom: 2,
paddingLeft: 8,
paddingRight: 8,
backgroundColor: '#8BE7C4',
color: '#1D1D26',
},
gdevelopAvatar: { width: 20, height: 20 },
thumbnail: { borderRadius: 4, aspectRatio: '16 / 9', maxWidth: '100%' },
statusContainer: {
@@ -79,12 +75,14 @@ const styles = {
};
const ChapterTile = ({
course,
chapter,
chapterIndex,
isComplete,
gdevelopTheme,
}: {|
chapter: ?CourseChapter,
course: Course,
chapter: CourseChapter,
isComplete: boolean,
chapterIndex: number,
gdevelopTheme: GDevelopTheme,
@@ -92,14 +90,14 @@ const ChapterTile = ({
return (
<Column expand>
<Spacer />
{chapter && chapter.isLocked ? (
{chapter.isLocked ? (
<Paper background="light" style={{ padding: 4 }}>
<LineStackLayout noMargin alignItems="center" justifyContent="center">
<div style={styles.statusContainer}>
<Lock fontSize="inherit" color="secondary" />
</div>
<Text color="secondary" noMargin>
<Trans>Unlock with {chapter.priceInCredits} credits</Trans>
<Trans>Unlock with the full course</Trans>
</Text>
</LineStackLayout>
</Paper>
@@ -112,13 +110,9 @@ const ChapterTile = ({
>
<CheckCircle fontSize="inherit" />
</div>
) : chapter && chapter.isFree ? (
) : course.isLocked && chapter.isFree ? (
<Line noMargin>
<div style={styles.freeChip}>
<Text noMargin color="inherit" size="body-small">
<Trans>Free</Trans>
</Text>
</div>
<Chip style={freeChipStyle} label={<Trans>Free!</Trans>} />
</Line>
) : (
<div style={styles.statusIconOnly}>
@@ -136,21 +130,13 @@ const ChapterTile = ({
<Trans>Chapter</Trans>
</Text>
</Line>
{chapter ? (
<Text
size="sub-title"
noMargin
color={chapter.isLocked ? 'secondary' : 'primary'}
>
{chapter.title}
</Text>
) : (
<Text>
<i>
<Trans>Coming soon</Trans>
</i>
</Text>
)}
<Text
size="sub-title"
noMargin
color={chapter.isLocked ? 'secondary' : 'primary'}
>
{chapter.title}
</Text>
<LargeSpacer />
</Column>
);
@@ -258,6 +244,7 @@ const CoursePreviewBanner = ({
))}
{index > 0 && <Spacer />}
<ChapterTile
course={course}
chapter={chapter}
chapterIndex={chapterIndex}
gdevelopTheme={gdevelopTheme}

View File

@@ -5,28 +5,47 @@ import * as React from 'react';
import {
listListedCourseChapters,
type CourseChapterListingData,
listListedCourses,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import { COURSE_CHAPTERS_FETCH_TIMEOUT } from '../Utils/GlobalFetchTimeouts';
type CourseChapterStoreState = {|
listedCourses: CourseListingData[] | null,
listedCourseChapters: CourseChapterListingData[] | null,
|};
const initialCourseChapterStoreState: CourseChapterStoreState = {
const initialCourseStoreState: CourseChapterStoreState = {
listedCourses: null,
listedCourseChapters: null,
};
const CourseChapterStoreContext = React.createContext<CourseChapterStoreState>(
initialCourseChapterStoreState
const CourseStoreContext = React.createContext<CourseChapterStoreState>(
initialCourseStoreState
);
type Props = {| children: React.Node |};
export const CourseChapterStoreStateProvider = (props: Props) => {
export const CourseStoreStateProvider = (props: Props) => {
const [listedCourses, setListedCourses] = React.useState<
CourseListingData[] | null
>(null);
const [listedCourseChapters, setListedCourseChapters] = React.useState<
CourseChapterListingData[] | null
>(null);
const loadCourses = React.useCallback(async () => {
try {
const fetchedListedCourses = await listListedCourses();
setListedCourses(fetchedListedCourses);
} catch (error) {
console.error(
'An error occurred when fetching courses in Shop API:',
error
);
}
}, []);
const loadCourseChapters = React.useCallback(async () => {
try {
const fetchedListedCourseChapters = await listListedCourseChapters();
@@ -41,24 +60,27 @@ export const CourseChapterStoreStateProvider = (props: Props) => {
React.useEffect(
() => {
console.info('Pre-fetching courses...');
loadCourses();
const timeoutId = setTimeout(() => {
console.info('Pre-fetching course chapters...');
loadCourseChapters();
}, COURSE_CHAPTERS_FETCH_TIMEOUT);
return () => clearTimeout(timeoutId);
},
[loadCourseChapters]
[loadCourseChapters, loadCourses]
);
return (
<CourseChapterStoreContext.Provider
<CourseStoreContext.Provider
value={{
listedCourses,
listedCourseChapters,
}}
>
{props.children}
</CourseChapterStoreContext.Provider>
</CourseStoreContext.Provider>
);
};
export default CourseChapterStoreContext;
export default CourseStoreContext;

View File

@@ -4,24 +4,17 @@ import * as React from 'react';
import { Trans } from '@lingui/macro';
import type {
CourseChapter,
Course,
LockedVideoBasedCourseChapter,
LockedTextBasedCourseChapter,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
import { ColumnStackLayout } from '../UI/Layout';
import Paper from '../UI/Paper';
import RaisedButton from '../UI/RaisedButton';
import FlatButton from '../UI/FlatButton';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
import GoldCompact from '../Profile/Subscription/Icons/GoldCompact';
import Coin from '../Credits/Icons/Coin';
import Lock from '../UI/CustomSvgIcons/Lock';
import Window from '../Utils/Window';
import PasswordPromptDialog from '../AssetStore/PasswordPromptDialog';
import { getYoutubeVideoIdFromUrl } from '../Utils/Youtube';
import AlertMessage from '../UI/AlertMessage';
const styles = {
videoAndMaterialsContainer: {
@@ -76,58 +69,17 @@ const LockedOverlay = () => (
);
type Props = {|
course: Course,
courseChapter: LockedVideoBasedCourseChapter | LockedTextBasedCourseChapter,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
onClickUnlock: () => void,
|};
const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
({ courseChapter, onBuyWithCredits }, ref) => {
const { openSubscriptionDialog } = React.useContext(
SubscriptionSuggestionContext
);
const [error, setError] = React.useState<React.Node>(null);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
({ course, courseChapter, onClickUnlock }, ref) => {
const { windowSize } = useResponsiveWindowSize();
const [password, setPassword] = React.useState<string>('');
const youtubeVideoId = courseChapter.videoUrl
? getYoutubeVideoIdFromUrl(courseChapter.videoUrl)
: null;
const [isPurchasing, setIsPurchasing] = React.useState<boolean>(false);
const onClickBuyWithCredits = React.useCallback(
async () => {
if (!courseChapter.isLocked) return;
setError(null);
setDisplayPasswordPrompt(false);
setIsPurchasing(true);
try {
await onBuyWithCredits(courseChapter, password);
} catch (error) {
console.error('An error occurred while buying this chapter', error);
setError(
<Trans>
An error occurred while buying this chapter. Please try again
later.
</Trans>
);
} finally {
setIsPurchasing(false);
}
},
[courseChapter, onBuyWithCredits, password]
);
const onWillBuyWithCredits = React.useCallback(
async () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onClickBuyWithCredits();
},
[onClickBuyWithCredits]
);
return (
<div style={styles.videoAndMaterialsContainer}>
@@ -152,57 +104,15 @@ const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
<Text noMargin size="sub-title">
<Trans>Unlock this lesson to finish the course</Trans>
</Text>
<Text noMargin>
<Trans>
Use your GDevelop credits to purchase lessons in this course;
or get a subscription to get them for free.
</Trans>
</Text>
<ResponsiveLineStackLayout
noMargin
noColumnMargin
forceMobileLayout={windowSize === 'medium'}
>
<RaisedButton
primary
fullWidth
icon={<GoldCompact fontSize="small" />}
disabled={isPurchasing}
label={<Trans>Get a subscription</Trans>}
onClick={() =>
openSubscriptionDialog({
analyticsMetadata: {
reason: 'Unlock course chapter',
recommendedPlanId: 'gdevelop_silver',
placementId: 'unlock-course-chapter',
},
})
}
/>
{courseChapter.priceInCredits && (
<FlatButton
fullWidth
leftIcon={<Coin fontSize="small" />}
disabled={isPurchasing}
label={
<Trans>Pay {courseChapter.priceInCredits} credits</Trans>
}
onClick={onWillBuyWithCredits}
/>
)}
</ResponsiveLineStackLayout>
{error && <AlertMessage kind="error">{error}</AlertMessage>}
<RaisedButton
primary
fullWidth
label={<Trans>Unlock the whole course</Trans>}
onClick={onClickUnlock}
/>
</ColumnStackLayout>
</Paper>
</div>
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onClickBuyWithCredits}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { Trans } from '@lingui/macro';
import type {
TextBasedCourseChapter,
CourseChapter,
Course,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout';
@@ -41,6 +41,7 @@ const styles = {
type Props = {|
chapterIndex: number,
course: Course,
courseChapter: TextBasedCourseChapter,
onOpenTemplate: (templateId?: string) => void,
onCompleteTask: (
@@ -50,7 +51,7 @@ type Props = {|
) => void,
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
onClickUnlock: () => void,
|};
const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
@@ -58,17 +59,19 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
{
chapterIndex,
courseChapter,
course,
onOpenTemplate,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
onBuyWithCredits,
onClickUnlock,
},
ref
) => {
return (
<ColumnStackLayout expand noMargin>
<CourseChapterTitle
course={course}
chapterIndex={chapterIndex}
courseChapter={courseChapter}
getChapterCompletion={getChapterCompletion}
@@ -76,8 +79,9 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
/>
{courseChapter.isLocked ? (
<LockedCourseChapterPreview
onBuyWithCredits={onBuyWithCredits}
course={course}
courseChapter={courseChapter}
onClickUnlock={onClickUnlock}
/>
) : courseChapter.templates.length > 0 ? (
<div style={styles.videoAndMaterialsContainer}>
@@ -88,7 +92,7 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
<Paper background="medium" style={styles.sideBar}>
<ColumnStackLayout noMargin>
{courseChapter.templates.map(template => (
<Line noMargin alignItems="center">
<Line noMargin alignItems="center" key={template.id}>
<Text noMargin>{rankLabel[chapterIndex + 1]}</Text>
&nbsp;
<Text noMargin>

View File

@@ -5,7 +5,7 @@ import { Trans } from '@lingui/macro';
import type {
VideoBasedCourseChapter,
CourseChapter,
Course,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout';
@@ -60,6 +60,7 @@ const styles = {
type Props = {|
chapterIndex: number,
course: Course,
courseChapter: VideoBasedCourseChapter,
onOpenTemplate: () => void,
onCompleteTask: (
@@ -69,19 +70,20 @@ type Props = {|
) => void,
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
onClickUnlock: () => void,
|};
const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
(
{
chapterIndex,
course,
courseChapter,
onOpenTemplate,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
onBuyWithCredits,
onClickUnlock,
},
ref
) => {
@@ -97,6 +99,7 @@ const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
return (
<ColumnStackLayout expand noMargin>
<CourseChapterTitle
course={course}
chapterIndex={chapterIndex}
courseChapter={courseChapter}
getChapterCompletion={getChapterCompletion}
@@ -104,8 +107,9 @@ const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
/>
{courseChapter.isLocked ? (
<LockedCourseChapterPreview
onBuyWithCredits={onBuyWithCredits}
course={course}
courseChapter={courseChapter}
onClickUnlock={onClickUnlock}
/>
) : (
<div style={styles.videoAndMaterialsContainer}>

View File

@@ -93,8 +93,6 @@ const parseVariablePath = (
segments.push({ type: 'property', value: currentSegment.trim() });
}
console.log('segments', segments);
return segments;
};

View File

@@ -373,9 +373,9 @@ const createObject: EditorFunction = {
scene: layout,
objectName: object_name,
objectType: object_type,
searchTerms: search_terms,
description,
twoDimensionalViewKind: two_dimensional_view_kind,
searchTerms: search_terms || '',
description: description || '',
twoDimensionalViewKind: two_dimensional_view_kind || '',
});
if (status === 'error') {
@@ -1340,8 +1340,8 @@ const put2dInstances: EditorFunction = {
args,
'brush_position'
);
const brush_size = SafeExtractor.extractStringProperty(args, 'brush_size');
const brush_end_position = SafeExtractor.extractNumberProperty(
const brush_size = SafeExtractor.extractNumberProperty(args, 'brush_size');
const brush_end_position = SafeExtractor.extractStringProperty(
args,
'brush_end_position'
);
@@ -1392,7 +1392,7 @@ const put2dInstances: EditorFunction = {
const brushPosition: Array<number> | null = brush_position
? brush_position.split(',').map(Number)
: null;
const brushSize = brush_size ? Number(brush_size) : 0;
const brushSize = brush_size || 0;
// Iterate on existing instances and remove them, and/or those inside the brush radius.
const instancesToDelete = new Set();
@@ -1447,7 +1447,7 @@ const put2dInstances: EditorFunction = {
project.getGameResolutionWidth() / 2,
project.getGameResolutionHeight() / 2,
];
const brushSize = brush_size ? Number(brush_size) : 0;
const brushSize = brush_size || 0;
const brushEndPosition = brush_end_position
? brush_end_position.split(',').map(Number)
: null;
@@ -1520,8 +1520,8 @@ const put2dInstances: EditorFunction = {
(brushEndPosition[1] - brushPosition[1]) / gridColumnCount;
modifiedAndCreatedInstances.forEach((instance, i) => {
const row = Math.floor(i / columnCount);
const column = i % columnCount;
const row = Math.floor(i / gridColumnCount);
const column = i % gridColumnCount;
instance.setX(brushPosition[0] + column * gridColumnSize);
instance.setY(brushPosition[1] + row * gridRowSize);
@@ -2472,6 +2472,12 @@ const addOrEditVariable: EditorFunction = {
}
variablesContainer = project.getLayout(scene_name).getVariables();
} else if (variable_scope === 'object') {
if (!object_name) {
return makeGenericFailure(
`Missing "object_name" argument, required to edit an object variable.`
);
}
let objectsContainer;
if (scene_name) {
if (!project.hasLayoutNamed(scene_name)) {

View File

@@ -75,7 +75,7 @@ export const enumerateEffectsMetadata = (
getValue: (effect: gdEffect) =>
effect.hasBooleanParameter(parameterName)
? effect.getBooleanParameter(parameterName)
: defaultValue === '1',
: defaultValue === 'true',
setValue: (effect: gdEffect, newValue: boolean) =>
effect.setBooleanParameter(parameterName, newValue),
getLabel,

View File

@@ -211,6 +211,15 @@ const Effect = React.forwardRef(
return advancedPropertiesSchema.some((field: Field) => {
const name = field.valueType ? field.name : null;
if (!name) return false;
if (
field.valueType === 'number'
? !effect.hasDoubleParameter(name)
: field.valueType === 'boolean'
? !effect.hasBooleanParameter(name)
: !effect.hasStringParameter(name)
) {
return false;
}
const current =
field.valueType === 'number'

View File

@@ -140,5 +140,5 @@ export const onlineCordovaIosExporter = {
key: 'onlinecordovaiosexport',
tabName: <Trans>iOS</Trans>,
name: <Trans>iOS</Trans>,
helpPage: '/publishing/android_and_ios',
helpPage: '/publishing/ios',
};

View File

@@ -1,24 +1,24 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import Text from '../../../../UI/Text';
import Text from '../../UI/Text';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
} from '../../UI/Layout';
import {
type Badge,
type Achievement,
} from '../../../../Utils/GDevelopServices/Badge';
import { Column } from '../../../../UI/Grid';
import Window from '../../../../Utils/Window';
import Coin from '../../../../Credits/Icons/Coin';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
} from '../../Utils/GDevelopServices/Badge';
import { Column } from '../../UI/Grid';
import Window from '../../Utils/Window';
import Coin from '../../Credits/Icons/Coin';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { I18n } from '@lingui/react';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import TextButton from '../../../../UI/TextButton';
import RouterContext from '../../../RouterContext';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import TextButton from '../../UI/TextButton';
import RouterContext from '../../MainFrame/RouterContext';
type CreditItemType = 'badge' | 'feedback';
type BadgeInfo = {|

View File

@@ -7,7 +7,7 @@ import DashboardWidget, {
import { ColumnStackLayout } from '../../UI/Layout';
import Coin from '../../Credits/Icons/Coin';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { EarnCredits } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
import { EarnCredits } from './EarnCredits';
import TextButton from '../../UI/TextButton';
import { Trans } from '@lingui/macro';

View File

@@ -26,6 +26,9 @@ export type EditorContainerExtraProps = {|
// Homepage
storageProviders?: Array<StorageProvider>,
// Ask AI
mode?: 'chat' | 'agent',
|};
export type SceneEventsOutsideEditorChanges = {|
@@ -88,6 +91,7 @@ export type RenderEditorContainerProps = {|
variantName: string
) => void,
openObjectEvents: (extensionName: string, objectName: string) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
// Events function management:
onLoadEventsFunctionsExtensions: () => Promise<void>,

View File

@@ -165,6 +165,10 @@ export class CustomObjectEditorContainer extends React.Component<RenderEditorCon
// No thing to be done.
}
onSceneObjectsDeleted(scene: gdLayout) {
// No thing to be done.
}
onSceneEventsModifiedOutsideEditor(changes: SceneEventsOutsideEditorChanges) {
// No thing to be done.
}

View File

@@ -1,220 +0,0 @@
// @flow
import { t, Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import * as React from 'react';
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
import Text from '../../../UI/Text';
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
import FlatButton from '../../../UI/FlatButton';
import TextField from '../../../UI/TextField';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import useAlertDialog from '../../../UI/Alert/useAlertDialog';
import Paper from '../../../UI/Paper';
import { Column, Line } from '../../../UI/Grid';
import CreateProfile from '../../../Profile/CreateProfile';
import LeftLoader from '../../../UI/LeftLoader';
import { submitQuestion } from '../../../Utils/GDevelopServices/Question';
import AlertMessage from '../../../UI/AlertMessage';
import Window from '../../../Utils/Window';
import Link from '../../../UI/Link';
import SquaredInfo from '../../../UI/CustomSvgIcons/SquaredInfo';
type Props = {|
onClose: () => void,
|};
const styles = {
infoIcon: { width: 24, height: 24 },
};
const maxLength = 500;
const minLength = 20;
const AnyQuestionDialog = ({ onClose }: Props) => {
const [isLoading, setIsLoading] = React.useState(false);
const [questionText, setQuestionText] = React.useState('');
const [error, setError] = React.useState<?Error>(null);
const [wasQuestionSubmitted, setWasQuestionSubmitted] = React.useState(false);
const { showAlert } = useAlertDialog();
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { limits } = authenticatedUser;
const onSubmit = React.useCallback(
async () => {
if (!questionText || wasQuestionSubmitted) return;
const { profile, getAuthorizationHeader } = authenticatedUser;
if (!profile) return;
if (questionText.length < minLength) {
showAlert({
title: t`Your question is too short`,
message: t`Your question must be at least ${minLength} characters long.`,
});
return;
}
if (
limits &&
limits.quotas['ask-question'] &&
limits.quotas['ask-question'].limitReached
) {
showAlert({
title: t`You've reached your limit`,
message: t`You've reached your limit of questions. Wait a bit and try again tomorrow, or get a subscription to unlock more questions!`,
});
return;
}
try {
setError(null);
setIsLoading(true);
await submitQuestion(getAuthorizationHeader, {
userId: profile.id,
questionText,
});
setWasQuestionSubmitted(true);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
},
[questionText, wasQuestionSubmitted, authenticatedUser, showAlert, limits]
);
const actions = [
<LeftLoader isLoading={isLoading}>
<FlatButton
key="cancel"
label={<Trans>Close</Trans>}
primary={false}
onClick={onClose}
/>
</LeftLoader>,
<DialogPrimaryButton
key="send"
label={<Trans>Send the question</Trans>}
color="success"
onClick={onSubmit}
disabled={isLoading || wasQuestionSubmitted}
/>,
];
return (
<I18n>
{({ i18n }) => (
<Dialog
title={<Trans>Ask any question</Trans>}
open
actions={actions}
onRequestClose={onClose}
onApply={onSubmit}
maxWidth="sm"
>
<ColumnStackLayout noMargin>
<Text size="body">
<Trans>
Write your question as precisely as possible. Tell where you're
blocked or what you want to do.
</Trans>
</Text>
{!authenticatedUser.authenticated && (
<Paper background="dark" variant="outlined">
<Line justifyContent="center">
<CreateProfile
onOpenLoginDialog={authenticatedUser.onOpenLoginDialog}
onOpenCreateAccountDialog={
authenticatedUser.onOpenCreateAccountDialog
}
message={
<Trans>
Create an account first to send your question.
</Trans>
}
/>
</Line>
</Paper>
)}
<TextField
value={questionText}
multiline
rows={3}
floatingLabelText={<Trans>Your question</Trans>}
onChange={(e, value) => {
setQuestionText(value);
}}
fullWidth
disabled={isLoading || wasQuestionSubmitted}
maxLength={maxLength}
helperMarkdownText={i18n._(
t`**${
questionText.length
}**/${maxLength} characters. You'll receive your answer by email.`
)}
/>
{error ? (
<AlertMessage kind="error">
<Trans>
An error happened while sending your question. Please verify
your internet connection or try again later.
</Trans>
</AlertMessage>
) : wasQuestionSubmitted ? (
<AlertMessage kind="info">
<Trans>
Your question has been sent! You'll receive your answer by
email.
</Trans>
</AlertMessage>
) : null}
<Paper background="light" variant="outlined">
<Column expand>
<LineStackLayout expand alignItems="center">
<SquaredInfo style={styles.infoIcon} />
<Text size="body-small">
<Trans>
You can also ask your question on{' '}
<Link
onClick={() =>
Window.openExternalURL('https://forum.gdevelop.io')
}
href="#"
>
the forum
</Link>
, on{' '}
<Link
onClick={() =>
Window.openExternalURL('https://discord.gg/gdevelop')
}
href="#"
>
the GDevelop Discord server
</Link>{' '}
or{' '}
<Link
onClick={() =>
Window.openExternalURL(
'https://gdevelop.io/pricing/support'
)
}
href="#"
>
book fast professional support
</Link>
.
</Trans>
</Text>
</LineStackLayout>
</Column>
</Paper>
</ColumnStackLayout>
</Dialog>
)}
</I18n>
);
};
export default AnyQuestionDialog;

View File

@@ -23,22 +23,21 @@ const styles = {
// Styles to give the impression of pressing an element.
const useStylesForWidget = ({
useDefaultDisabledStyle,
noBorder,
disableHoverAndFocusEffects,
}: {|
useDefaultDisabledStyle?: boolean,
noBorder?: boolean,
disableHoverAndFocusEffects?: boolean,
|}) =>
makeStyles(theme => {
const rootStyles = {
boxSizing: 'border-box',
border: `1px solid ${theme.palette.text.primary}`,
borderBottom: `6px solid ${theme.palette.text.primary}`,
border: !noBorder ? `1px solid ${theme.palette.text.secondary}` : 'none',
transition: 'background-color 100ms ease',
'&:disabled': useDefaultDisabledStyle
? {
opacity: theme.palette.action.disabledOpacity,
border: `1px solid ${theme.palette.text.secondary}`,
borderBottom: `6px solid ${theme.palette.text.secondary}`,
}
: undefined,
};
@@ -66,6 +65,7 @@ type Props = {|
size: 'small' | 'large' | 'banner',
disabled?: boolean,
useDefaultDisabledStyle?: boolean,
noBorder?: boolean,
|};
export const CardWidget = ({
@@ -74,9 +74,11 @@ export const CardWidget = ({
size,
disabled,
useDefaultDisabledStyle,
noBorder,
}: Props) => {
const classes = useStylesForWidget({
useDefaultDisabledStyle,
noBorder,
disableHoverAndFocusEffects: !onClick,
});
const { isMobile } = useResponsiveWindowSize();

View File

@@ -587,10 +587,7 @@ const CreateSection = ({
onSelectPrivateGameTemplateListingData
}
i18n={i18n}
columnsCount={getExampleItemsColumns(
windowSize,
isLandscape
)}
getColumnsFromWindowSize={getExampleItemsColumns}
hideSearch
onlyShowGames
/>

View File

@@ -9,12 +9,10 @@ import {
type StorageProvider,
} from '../../../../ProjectsStorage';
import { marginsSize } from '../../../../UI/Grid';
import { sendGameTemplateInformationOpened } from '../../../../Utils/Analytics/EventSender';
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import { type PrivateGameTemplate } from '../../../../Utils/GDevelopServices/Asset';
import { type CarouselThumbnail } from '../../../../UI/Carousel';
import { type GDevelopTheme } from '../../../../UI/Theme';
import {
ExampleTile,
PrivateGameTemplateTile,
@@ -155,87 +153,6 @@ export const transformCloudProjectsIntoFileMetadataWithStorageProviderName = (
.filter(Boolean);
};
const formatGameTemplateListingDataForCarousel = ({
gameTemplateListingData,
onSelectGameTemplate,
i18n,
receivedGameTemplates,
}: {|
gameTemplateListingData: PrivateGameTemplateListingData,
onSelectGameTemplate: PrivateGameTemplateListingData => void,
i18n: I18nType,
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|}): CarouselThumbnail => {
const isTemplateOwned =
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
receivedGameTemplate =>
receivedGameTemplate.id === gameTemplateListingData.id
);
return {
id: gameTemplateListingData.id,
title: gameTemplateListingData.name,
thumbnailUrl: gameTemplateListingData.thumbnailUrls[0],
onClick: () => {
sendGameTemplateInformationOpened({
gameTemplateName: gameTemplateListingData.name,
gameTemplateId: gameTemplateListingData.id,
source: 'homepage',
});
onSelectGameTemplate(gameTemplateListingData);
},
overlayText: getProductPriceOrOwnedLabel({
i18n,
productListingData: gameTemplateListingData,
owned: isTemplateOwned,
}),
overlayTextPosition: 'topLeft',
};
};
const formatExampleShortHeaderForCarousel = ({
exampleShortHeader,
onSelectExample,
}: {|
exampleShortHeader: ExampleShortHeader,
onSelectExample: ExampleShortHeader => void,
|}) => {
return {
id: exampleShortHeader.id,
title: exampleShortHeader.name,
onClick: () => onSelectExample(exampleShortHeader),
thumbnailUrl: exampleShortHeader.previewImageUrls[0],
};
};
const formatItemForCarousel = ({
item,
onSelectGameTemplate,
onSelectExample,
i18n,
receivedGameTemplates,
}: {
item: PrivateGameTemplateListingData | ExampleShortHeader,
onSelectGameTemplate: PrivateGameTemplateListingData => void,
onSelectExample: ExampleShortHeader => void,
i18n: I18nType,
receivedGameTemplates: ?Array<PrivateGameTemplate>,
}): CarouselThumbnail => {
if (item.previewImageUrls) {
return formatExampleShortHeaderForCarousel({
exampleShortHeader: item,
onSelectExample: onSelectExample,
});
} else {
return formatGameTemplateListingDataForCarousel({
i18n,
onSelectGameTemplate: onSelectGameTemplate,
gameTemplateListingData: item,
receivedGameTemplates: receivedGameTemplates,
});
}
};
const formatItemForGrid = ({
item,
onSelectGameTemplate,
@@ -288,6 +205,7 @@ export const getExampleAndTemplateTiles = ({
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
i18n,
gdevelopTheme,
numberOfItemsExclusivelyInCarousel = 0,
numberOfItemsInCarousel = 0,
privateGameTemplatesPeriodicity,
@@ -301,21 +219,14 @@ export const getExampleAndTemplateTiles = ({
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
i18n: I18nType,
gdevelopTheme: GDevelopTheme,
numberOfItemsExclusivelyInCarousel?: number,
numberOfItemsInCarousel?: number,
privateGameTemplatesPeriodicity: number,
showOwnedGameTemplatesFirst?: boolean,
|}): {|
carouselThumbnailItems: Array<CarouselThumbnail>,
gridItemsCompletingCarousel: Array<React.Node>,
allGridItems: Array<React.Node>,
|} => {
|}): Array<React.Node> => {
if (!exampleShortHeaders || !privateGameTemplateListingDatas) {
return {
carouselThumbnailItems: [],
gridItemsCompletingCarousel: [],
allGridItems: [],
};
return [];
}
const exampleShortHeadersWithThumbnails = exampleShortHeaders.filter(
exampleShortHeader =>
@@ -328,12 +239,6 @@ export const getExampleAndTemplateTiles = ({
!exampleShortHeader.previewImageUrls[0]
);
const carouselItems: Array<
PrivateGameTemplateListingData | ExampleShortHeader
> = [];
const itemsCompletingCarousel: Array<
PrivateGameTemplateListingData | ExampleShortHeader
> = [];
const allItems: Array<
PrivateGameTemplateListingData | ExampleShortHeader
> = [];
@@ -363,29 +268,13 @@ export const getExampleAndTemplateTiles = ({
// First handle example.
if (exampleShortHeader) {
// Handle carousel.
if (carouselItems.length < numberOfItemsInCarousel) {
carouselItems.push(exampleShortHeader);
}
// Handle grid.
allItems.push(exampleShortHeader);
if (carouselItems.length > numberOfItemsExclusivelyInCarousel) {
itemsCompletingCarousel.push(exampleShortHeader);
}
}
// Then handle private game template if in the right periodicity.
if (shouldAddPrivateGameTemplate && privateGameTemplateListingData) {
// Handle carousel.
if (carouselItems.length < numberOfItemsInCarousel) {
carouselItems.push(privateGameTemplateListingData);
}
// Handle grid.
if (privateGameTemplateListingData) {
allItems.push(privateGameTemplateListingData);
if (carouselItems.length > numberOfItemsExclusivelyInCarousel) {
itemsCompletingCarousel.push(privateGameTemplateListingData);
}
}
}
@@ -401,26 +290,6 @@ export const getExampleAndTemplateTiles = ({
allItems.push(exampleShortHeader);
});
const carouselThumbnailItems = carouselItems.map(item =>
formatItemForCarousel({
item,
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
onSelectExample: onSelectExampleShortHeader,
i18n,
receivedGameTemplates,
})
);
const gridItemsCompletingCarousel = itemsCompletingCarousel.map(item =>
formatItemForGrid({
item,
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
onSelectExample: onSelectExampleShortHeader,
i18n,
receivedGameTemplates,
})
);
const allGridItems = allItems
.sort((item1, item2) => {
if (showOwnedGameTemplatesFirst) {
@@ -452,5 +321,5 @@ export const getExampleAndTemplateTiles = ({
})
);
return { carouselThumbnailItems, gridItemsCompletingCarousel, allGridItems };
return allGridItems;
};

View File

@@ -1,360 +0,0 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { makeStyles } from '@material-ui/styles';
import GridList from '@material-ui/core/GridList';
import GridListTile from '@material-ui/core/GridListTile';
import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import { SectionRow } from '../SectionContainer';
import GuidedLessons from '../InAppTutorials/GuidedLessons';
import { formatTutorialToImageTileComponent } from '../LearnSection';
import ImageTileRow from '../../../../UI/ImageTileRow';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import Text from '../../../../UI/Text';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import { CardWidget } from '../CardWidget';
import Window from '../../../../Utils/Window';
import { ColumnStackLayout } from '../../../../UI/Layout';
import { type GuidedLessonsRecommendation } from '../../../../Utils/GDevelopServices/User';
import PreferencesContext from '../../../Preferences/PreferencesContext';
import { SurveyCard } from './SurveyCard';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import PromotionsSlideshow from '../../../../Promotions/PromotionsSlideshow';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import FlatButton from '../../../../UI/FlatButton';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
const styles = {
textTutorialContent: {
padding: 20,
flex: 1,
display: 'flex',
},
};
const useStyles = makeStyles({
tile: {
width: '100%',
display: 'flex',
justifyContent: 'center',
},
});
const getTextTutorialsColumnsFromWidth = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 2;
case 'large':
return 4;
case 'xlarge':
return 5;
default:
return 3;
}
};
const getVideoTutorialsColumnsFromWidth = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 5 : 2;
case 'medium':
return 3;
case 'large':
return 5;
case 'xlarge':
return 6;
default:
return 3;
}
};
const getTutorialsLimitsFromWidth = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 5 : 3;
case 'medium':
return 3;
case 'large':
return 5;
case 'xlarge':
return 5;
default:
return 3;
}
};
type TextTutorialsRowProps = {|
tutorials: Array<Tutorial>,
i18n: I18nType,
|};
const TextTutorialsRow = ({ tutorials, i18n }: TextTutorialsRowProps) => {
const classes = useStyles();
const { isLandscape, windowSize } = useResponsiveWindowSize();
return (
<>
<Column noMargin>
<Text size="section-title" noMargin>
<Trans>Read</Trans>
</Text>
<Text>
<Trans>
Text-based content directly from GDevelops site and Wiki.
</Trans>
</Text>
</Column>
<GridList
cols={getTextTutorialsColumnsFromWidth(windowSize, isLandscape)}
cellHeight="auto"
spacing={10}
>
{tutorials.map(tutorial => (
<GridListTile key={tutorial.id} classes={{ tile: classes.tile }}>
<CardWidget
onClick={() =>
Window.openExternalURL(
selectMessageByLocale(i18n, tutorial.linkByLocale)
)
}
size="large"
>
<div style={styles.textTutorialContent}>
<ColumnStackLayout expand justifyContent="center" useFullHeight>
<Text noMargin size="block-title">
{selectMessageByLocale(i18n, tutorial.titleByLocale)}
</Text>
<Text noMargin size="body" color="secondary">
{selectMessageByLocale(i18n, tutorial.descriptionByLocale)}
</Text>
</ColumnStackLayout>
</div>
</CardWidget>
</GridListTile>
))}
</GridList>
</>
);
};
type Props = {|
authenticatedUser: AuthenticatedUser,
selectInAppTutorial: (tutorialId: string) => void,
onStartSurvey: null | (() => void),
hasFilledSurveyAlready: boolean,
onOpenProfile: () => void,
onCreateProjectFromExample: (
exampleShortHeader: ExampleShortHeader,
newProjectSetup: NewProjectSetup,
i18n: I18nType,
isQuickCustomization?: boolean
) => Promise<void>,
askToCloseProject: () => Promise<boolean>,
|};
const RecommendationList = ({
authenticatedUser,
selectInAppTutorial,
onStartSurvey,
hasFilledSurveyAlready,
onOpenProfile,
onCreateProjectFromExample,
askToCloseProject,
}: Props) => {
const { recommendations, limits } = authenticatedUser;
const { tutorials } = React.useContext(TutorialContext);
const {
getTutorialProgress,
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
const [
selectedTutorial,
setSelectedTutorial,
] = React.useState<Tutorial | null>(null);
if (!recommendations) return <PlaceholderLoader />;
const recommendedTutorials = tutorials
? recommendations
.map(recommendation =>
recommendation.type === 'gdevelop-tutorial'
? tutorials.find(tutorial => tutorial.id === recommendation.id)
: null
)
.filter(Boolean)
: [];
const recommendedVideoTutorials = recommendedTutorials.filter(
tutorial => tutorial.type === 'video'
);
const recommendedTextTutorials = recommendedTutorials.filter(
tutorial => tutorial.type === 'text'
);
// $FlowIgnore
const guidedLessonsRecommendation: ?GuidedLessonsRecommendation = recommendations.find(
recommendation => recommendation.type === 'guided-lessons'
);
const guidedLessonsIds = guidedLessonsRecommendation
? guidedLessonsRecommendation.lessonsIds
: null;
const getInAppTutorialPartProgress = ({
tutorialId,
}: {
tutorialId: string,
}) => {
const tutorialProgress = getTutorialProgress({
tutorialId,
userId: authenticatedUser.profile
? authenticatedUser.profile.id
: undefined,
});
if (!tutorialProgress || !tutorialProgress.progress) return 0;
return tutorialProgress.progress[0]; // guided lessons only have one part.
};
return (
<I18n>
{({ i18n }) => {
const items = [];
if (onStartSurvey && !hasFilledSurveyAlready)
items.push(
<SectionRow key="start-survey">
<SurveyCard
onStartSurvey={onStartSurvey}
hasFilledSurveyAlready={false}
/>
</SectionRow>
);
if (guidedLessonsRecommendation) {
const displayTextAfterGuidedLessons = guidedLessonsIds
? guidedLessonsIds
.map(tutorialId => getInAppTutorialPartProgress({ tutorialId }))
.every(progress => progress === 100)
: false;
items.push(
<SectionRow key="guided-lessons">
<Line justifyContent="space-between" noMargin alignItems="center">
<Text size="section-title" noMargin>
<Trans>Build game mechanics</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
/>
)}
</Line>
<GuidedLessons
selectInAppTutorial={selectInAppTutorial}
lessonsIds={guidedLessonsIds}
/>
{displayTextAfterGuidedLessons && (
<Text>
<Trans>
Congratulations on completing this selection of guided
lessons! Find all lessons in the Learn section.
</Trans>
</Text>
)}
</SectionRow>
);
}
if (recommendedVideoTutorials.length) {
items.push(
<SectionRow key="videos">
<ImageTileRow
title={<Trans>Get started with game creation</Trans>}
margin="dense"
items={recommendedVideoTutorials.map(tutorial =>
formatTutorialToImageTileComponent({
i18n,
limits,
tutorial,
onSelectTutorial: setSelectedTutorial,
})
)}
getColumnsFromWindowSize={getVideoTutorialsColumnsFromWidth}
getLimitFromWindowSize={getTutorialsLimitsFromWidth}
/>
</SectionRow>
);
}
if (onStartSurvey && hasFilledSurveyAlready)
items.push(
<SectionRow key="start-survey">
<SurveyCard
onStartSurvey={onStartSurvey}
hasFilledSurveyAlready
/>
</SectionRow>
);
items.push(
<SectionRow key="promotions">
<Text size="section-title" noMargin>
<Trans>Discover the ecosystem</Trans>
</Text>
<Spacer />
<PromotionsSlideshow />
</SectionRow>
);
if (recommendedTextTutorials.length) {
items.push(
<SectionRow key="texts">
<TextTutorialsRow
tutorials={recommendedTextTutorials}
i18n={i18n}
/>
</SectionRow>
);
}
return (
<>
{items}
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</>
);
}}
</I18n>
);
};
export default RecommendationList;

View File

@@ -1,293 +0,0 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { type I18n as I18nType } from '@lingui/core';
import Text from '../../../../UI/Text';
import { ColumnStackLayout } from '../../../../UI/Layout';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { useOnlineStatus } from '../../../../Utils/OnlineStatus';
import TreeLeaves from '../../../../UI/CustomSvgIcons/TreeLeaves';
import SectionContainer from '../SectionContainer';
import RaisedButton from '../../../../UI/RaisedButton';
import useForceUpdate from '../../../../Utils/UseForceUpdate';
import { LargeSpacer, Line } from '../../../../UI/Grid';
import CircularProgress from '../../../../UI/CircularProgress';
import { type UserSurvey as UserSurveyType } from '../../../../Utils/GDevelopServices/User';
import UserSurvey from './UserSurvey';
import {
clearUserSurveyPersistedState,
hasStartedUserSurvey,
} from './UserSurveyStorage';
import LinearProgress from '../../../../UI/LinearProgress';
import PreferencesContext from '../../../Preferences/PreferencesContext';
import RecommendationList from './RecommendationList';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import { delay } from '../../../../Utils/Delay';
import Checkbox from '../../../../UI/Checkbox';
import { sendUserSurveyCompleted } from '../../../../Utils/Analytics/EventSender';
import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
const styles = {
icon: {
width: 80,
height: 80,
margin: 20,
},
middlePageButtonContainer: {
width: '100%',
maxWidth: 300, // Make buttons larger but not too much.
marginBottom: '15%', // Used to display the content of the section higher than at the center.
},
bottomPageButtonContainer: {
width: '100%',
maxWidth: 300, // Make buttons larger but not too much.
marginBottom: 30, // Used to giver some space between the buttons and the screen bottom border.
},
linearProgress: { width: 200 },
getFormContainerStyle: (isMobile: boolean) => ({
marginTop: 20,
// Take full width on mobile.
width: isMobile ? '95%' : 300,
}),
questionnaireFinishedImage: { aspectRatio: '263 / 154' },
};
const questionnaireFinishedImageSource = 'res/questionnaire/welcome-back.svg';
type Props = {|
onUserSurveyStarted: () => void,
onUserSurveyHidden: () => void,
selectInAppTutorial: (tutorialId: string) => void,
onOpenProfile: () => void,
onCreateProjectFromExample: (
exampleShortHeader: ExampleShortHeader,
newProjectSetup: NewProjectSetup,
i18n: I18nType,
isQuickCustomization?: boolean
) => Promise<void>,
askToCloseProject: () => Promise<boolean>,
|};
const GetStartedSection = ({
selectInAppTutorial,
onUserSurveyStarted,
onUserSurveyHidden,
onOpenProfile,
onCreateProjectFromExample,
askToCloseProject,
}: Props) => {
const isFillingOutSurvey = hasStartedUserSurvey();
const isOnline = useOnlineStatus();
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const {
profile,
creatingOrLoggingInAccount,
onEditProfile,
loginState,
} = authenticatedUser;
const {
values: preferences,
setShowGetStartedSectionByDefault,
} = React.useContext(PreferencesContext);
const recommendationsGettingDelayPromise = React.useRef<?Promise<void>>(null);
const forceUpdate = useForceUpdate();
const [step, setStep] = React.useState<
'survey' | 'surveyFinished' | 'recommendations'
>(isFillingOutSurvey ? 'survey' : 'recommendations');
const [errorSendingSurvey, setErrorSendingSurvey] = React.useState<boolean>(
false
);
const onSurveyFinished = async (survey: UserSurveyType) => {
try {
setStep('surveyFinished');
// Artificial delay to build up expectations.
recommendationsGettingDelayPromise.current = delay(2500);
await Promise.all([
onEditProfile({ survey }, preferences),
recommendationsGettingDelayPromise.current,
]);
sendUserSurveyCompleted();
clearUserSurveyPersistedState();
} catch (error) {
console.error('An error occurred when sending survey:', error);
setErrorSendingSurvey(true);
} finally {
recommendationsGettingDelayPromise.current = null;
setStep('recommendations');
}
};
React.useEffect(
() => {
if (!authenticatedUser.authenticated) clearUserSurveyPersistedState();
},
[authenticatedUser.authenticated]
);
if (
(creatingOrLoggingInAccount || loginState === 'loggingIn') &&
// Do not display loader if the user is already seeing the recommendations.
// It can happen when the user profile is refreshed while the recommendations
// are displayed. This way, the loader is not displayed unnecessarily.
step !== 'recommendations' &&
!recommendationsGettingDelayPromise.current
) {
return (
<SectionContainer flexBody>
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<CircularProgress size={40} />
</ColumnStackLayout>
</ColumnStackLayout>
</SectionContainer>
);
}
if (!isOnline || errorSendingSurvey) {
return (
<SectionContainer flexBody>
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
{errorSendingSurvey ? (
<>
<Text size="title" align="center">
<Trans>Error when sending survey.</Trans>
</Text>
<TreeLeaves style={styles.icon} />
<Text size="body2" noMargin align="center">
<Trans>
Verify your internet connection and try again later.
</Trans>
</Text>
</>
) : (
<>
<Text size="title" align="center">
<Trans>You seem to be offline</Trans>
</Text>
<TreeLeaves style={styles.icon} />
<Text size="body2" noMargin align="center">
<Trans>
Verify your internet connection to access your personalized
content.
</Trans>
</Text>
<div style={styles.middlePageButtonContainer}>
<Line expand>
<RaisedButton
primary
label={<Trans>Refresh</Trans>}
onClick={forceUpdate}
fullWidth
/>
</Line>
</div>
</>
)}
</ColumnStackLayout>
</SectionContainer>
);
}
if (step === 'surveyFinished') {
return (
<SectionContainer flexBody>
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<Text size="title" align="center">
<Trans>Alright let's see what we have for you...</Trans>
</Text>
<img
src={questionnaireFinishedImageSource}
alt="You as the red hero coming back to life"
style={styles.questionnaireFinishedImage}
/>
<Text size="body2" noMargin align="center">
<Trans>Just one second please...</Trans>
</Text>
<Line>
<LinearProgress
variant="indeterminate"
style={styles.linearProgress}
/>
</Line>
</ColumnStackLayout>
</SectionContainer>
);
}
if (step === 'recommendations') {
return (
<>
<SectionContainer flexBody showUrgentAnnouncements>
<RecommendationList
authenticatedUser={authenticatedUser}
selectInAppTutorial={selectInAppTutorial}
onOpenProfile={onOpenProfile}
onStartSurvey={
profile
? () => {
setStep('survey');
}
: null
}
hasFilledSurveyAlready={profile ? !!profile.survey : false}
onCreateProjectFromExample={onCreateProjectFromExample}
askToCloseProject={askToCloseProject}
/>
{authenticatedUser.recommendations && (
<Line justifyContent="center" alignItems="center">
<Checkbox
label={<Trans>Don't show this screen on next startup</Trans>}
checked={!preferences.showGetStartedSectionByDefault}
onCheck={(e, checked) =>
setShowGetStartedSectionByDefault(!checked)
}
/>
</Line>
)}
<LargeSpacer />
</SectionContainer>
</>
);
}
return (
<UserSurvey
onCompleted={onSurveyFinished}
onStarted={onUserSurveyStarted}
/>
);
};
const GetStartedSectionWithErrorBoundary = (props: Props) => (
<ErrorBoundary
componentTitle={<Trans>Get started section</Trans>}
scope="start-page-get-started"
>
<GetStartedSection {...props} />
</ErrorBoundary>
);
export default GetStartedSectionWithErrorBoundary;

View File

@@ -10,7 +10,6 @@ import HammerIcon from '../../../UI/CustomSvgIcons/Hammer';
import SchoolIcon from '../../../UI/CustomSvgIcons/School';
import ControllerIcon from '../../../UI/CustomSvgIcons/Controller';
import BookLeafIcon from '../../../UI/CustomSvgIcons/BookLeaf';
import SunIcon from '../../../UI/CustomSvgIcons/Sun';
import StoreIcon from '../../../UI/CustomSvgIcons/Store';
import Preferences from '../../../UI/CustomSvgIcons/Preferences';
import GDevelopGLogo from '../../../UI/CustomSvgIcons/GDevelopGLogo';
@@ -43,13 +42,7 @@ export const styles = {
},
};
export type HomeTab =
| 'get-started'
| 'create'
| 'learn'
| 'play'
| 'shop'
| 'team-view';
export type HomeTab = 'create' | 'learn' | 'play' | 'shop' | 'team-view';
export type GetIconFunction = ({
color: string,
@@ -64,14 +57,6 @@ export type HomePageMenuTab = {|
|};
const homePageMenuTabs: { [tab: HomeTab]: HomePageMenuTab } = {
'get-started': {
label: <Trans>Start</Trans>,
tab: 'get-started',
id: 'home-get-started-tab',
getIcon: ({ color, fontSize }) => (
<SunIcon fontSize={fontSize} color={color} />
),
},
create: {
label: <Trans>Create</Trans>,
tab: 'create',
@@ -131,15 +116,14 @@ export const getTabsToDisplay = ({
limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts
);
const displayTeachTab =
!shouldHideClassroomTab(limits) && !isNativeMobileApp();
const tabs: HomeTab[] = [
'get-started',
'create',
!shouldHideClassroomTab(limits) && !isNativeMobileApp()
? 'team-view'
: null,
displayShopTab ? 'shop' : null,
'learn',
'create',
displayPlayTab ? 'play' : null,
displayShopTab ? 'shop' : null,
displayTeachTab ? 'team-view' : null,
].filter(Boolean);
return tabs.map(tab => homePageMenuTabs[tab]);
};

View File

@@ -0,0 +1,60 @@
// @flow
import { getTabsToDisplay } from './HomePageMenu';
import { isNativeMobileApp } from '../../../Utils/Platform';
import { limitsForStudentUser } from '../../../fixtures/GDevelopServicesTestData';
jest.mock('../../../Utils/Platform');
const mockFn = (fn: Function): JestMockFn<any, any> => fn;
describe('HomePageMenu', () => {
describe('getTabsToDisplay', () => {
beforeEach(() => {
mockFn(isNativeMobileApp).mockReset();
});
test('Default desktop user', () => {
mockFn(isNativeMobileApp).mockReturnValue(false);
const tabs = getTabsToDisplay({ limits: null });
expect(tabs.map(tab => tab.tab)).toEqual([
'learn',
'create',
'play',
'shop',
'team-view',
]);
});
test('Desktop user student', () => {
mockFn(isNativeMobileApp).mockReturnValue(false);
const tabs = getTabsToDisplay({
limits: limitsForStudentUser,
});
expect(tabs.map(tab => tab.tab)).toEqual(['learn', 'create']);
});
test('Default mobile user', () => {
mockFn(isNativeMobileApp).mockReturnValue(true);
const tabs = getTabsToDisplay({ limits: null });
expect(tabs.map(tab => tab.tab)).toEqual([
'learn',
'create',
'play',
'shop',
]);
});
test('Mobile student user', () => {
mockFn(isNativeMobileApp).mockReturnValue(true);
const tabs = getTabsToDisplay({ limits: limitsForStudentUser });
expect(tabs.map(tab => tab.tab)).toEqual(['learn', 'create']);
});
});
});

View File

@@ -1,7 +1,6 @@
// @flow
import * as React from 'react';
import { Trans, t } from '@lingui/macro';
import GridList from '@material-ui/core/GridList';
import GridListTile from '@material-ui/core/GridListTile';
import {
useResponsiveWindowSize,
@@ -44,6 +43,8 @@ import TopDownRPGMovement from './Icons/TopDownRPGMovement';
import FireABullet from './Icons/FireAbullet';
import CoopPlatformer from './Icons/CoopPlatformer';
import TilemapPlatformer from './Icons/TilemapPlatformer';
import Carousel from '../../../../UI/Carousel';
import { GridList } from '@material-ui/core';
const getColumnsFromWindowSize = (
windowSize: WindowSizeType,
@@ -63,6 +64,7 @@ const getColumnsFromWindowSize = (
}
};
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
@@ -80,13 +82,109 @@ const styles = {
},
};
export const allInAppTutorialsCards = [
{
id: KNIGHT_PLATFORMER_IN_APP_TUTORIAL_ID,
title: t`Platformer`,
description: t`Make a knight jump and run in this platformer game.`,
shortDescription: t`Make a knight jump and run.`,
durationInMinutes: 1,
renderImage: (props: any) => <Platformer {...props} />,
},
{
id: CAMERA_PARALLAX_IN_APP_TUTORIAL_ID,
title: t`Background and cameras`,
shortDescription: t`Follow a character with scrolling background.`,
description: t`Follow this Castlevania-type character with the camera, while the background scrolls.`,
durationInMinutes: 2,
renderImage: (props: any) => <Parallax {...props} />,
},
{
id: TOP_DOWN_RPG_MOVEMENT_ID,
title: t`Top-Down RPG Pixel Perfect`,
shortDescription: t`Move like in retro Pokemon games.`,
description: t`Make a character move like in a retro Pokemon game.`,
durationInMinutes: 2,
renderImage: (props: any) => <TopDownRPGMovement {...props} />,
},
{
id: JOYSTICK_IN_APP_TUTORIAL_ID,
title: t`Joystick controls`,
shortDescription: t`Control a spaceship with a joystick.`,
description: t`Control your spaceship with a joystick, while avoiding asteroids.`,
durationInMinutes: 1,
renderImage: (props: any) => <Joystick {...props} />,
},
{
id: OBJECT_3D_IN_APP_TUTORIAL_ID,
title: t`3D platforms`,
shortDescription: t`Place 3D platforms in a 2D game.`,
description: t`Place 3D platforms in this 2D platformer, creating a path to the end.`,
durationInMinutes: 2,
renderImage: (props: any) => <Object3D {...props} />,
},
{
id: HEALTH_BAR_IN_APP_TUTORIAL_ID,
title: t`Health bar`,
shortDescription: t`Add a health bar for handle damage.`,
description: t`Add a health bar to this jumping character, losing health when hitting spikes.`,
durationInMinutes: 2,
renderImage: (props: any) => <HealthBar {...props} />,
},
{
id: TILEMAP_PLATFORMER_ID,
title: t`Paint a Level with Tiles`,
shortDescription: t`Build dynamic levels with tiles.`,
description: t`Use a Tilemap to build a level and change it dynamically during the game.`,
durationInMinutes: 2,
renderImage: (props: any) => <TilemapPlatformer {...props} />,
},
{
id: FIRE_A_BULLET_ID,
title: t`Fire a Bullet`,
shortDescription: t`Fire bullets in an Asteroids game.`,
description: t`Fire bullets in this Asteroids game. Get ready for a Star Wars show.`,
durationInMinutes: 2,
renderImage: (props: any) => <FireABullet {...props} />,
},
{
id: TIMER_IN_APP_TUTORIAL_ID,
title: t`Time score`,
shortDescription: t`Add a time attack mode.`,
description: t`Add a time attack mode, where you have to reach the end as fast as possible.`,
durationInMinutes: 2,
renderImage: (props: any) => <Timer {...props} />,
},
{
id: COOP_PLATFORMER_ID,
title: t`Co-op Multiplayer`,
shortDescription: t`Transform a game into a multiplayer experience.`,
description: t`Transform this platformer into a co-op game, where two players can play together.`,
durationInMinutes: 3,
renderImage: (props: any) => <CoopPlatformer {...props} />,
},
{
id: PLINKO_MULTIPLIER_IN_APP_TUTORIAL_ID,
title: t`Score multiplier`,
shortDescription: t`Multiply scores with collectibles.`,
description: t`Transform this Plinko game with collectibles that multiply your score.`,
durationInMinutes: 3,
renderImage: (props: any) => <MultiplierScore {...props} />,
},
];
type Props = {|
selectInAppTutorial: (tutorialId: string) => void,
/** To use to restrict the lessons that are displayed. */
lessonsIds?: ?Array<string>,
displayAsCarousel?: boolean,
|};
const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
const GuidedLessons = ({
selectInAppTutorial,
lessonsIds,
displayAsCarousel,
}: Props) => {
const isOnline = useOnlineStatus();
const {
inAppTutorialShortHeaders,
@@ -103,6 +201,12 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
}: {|
tutorialId: string,
|}) => {
if (authenticatedUser.loginState === 'loggingIn') {
// Wait for login to finish to avoid showing an offline progress before the
// actual user's progress.
return 0;
}
const tutorialProgress = getTutorialProgress({
tutorialId,
userId: authenticatedUser.profile
@@ -113,109 +217,34 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
return tutorialProgress.progress[0]; // guided lessons only have one part.
};
const displayedGuidedLessonsIds = lessonsIds || guidedLessonsIds;
const lessonsCompleted = displayedGuidedLessonsIds.reduce(
(acc, tutorialId) => {
const tutorialProgress = getTutorialPartProgress({ tutorialId }) || 0;
return tutorialProgress === 100 ? acc + 1 : acc;
},
0
);
const lessonsCompleted = guidedLessonsIds.reduce((acc, tutorialId) => {
const tutorialProgress = getTutorialPartProgress({ tutorialId }) || 0;
return tutorialProgress === 100 ? acc + 1 : acc;
}, 0);
const lessonsProgress = Math.round(
(lessonsCompleted / displayedGuidedLessonsIds.length) * 100
(lessonsCompleted / guidedLessonsIds.length) * 100
);
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
const guidedLessonCards = [
{
id: KNIGHT_PLATFORMER_IN_APP_TUTORIAL_ID,
title: t`Platformer`,
description: t`Make a knight jump and run in this platformer game.`,
shortDescription: t`Make a knight jump and run.`,
durationInMinutes: 1,
renderImage: props => <Platformer {...props} />,
const guidedLessonCards = React.useMemo(
() => {
let displayedGuidedLessonsIds = guidedLessonsIds;
if (lessonsIds) {
displayedGuidedLessonsIds = lessonsIds;
}
if (displayAsCarousel) {
displayedGuidedLessonsIds = displayedGuidedLessonsIds.slice(
0,
numberOfColumns * (NUMBER_OF_SCROLLS + 1)
);
}
return allInAppTutorialsCards.filter(item =>
displayedGuidedLessonsIds.includes(item.id)
);
},
{
id: CAMERA_PARALLAX_IN_APP_TUTORIAL_ID,
title: t`Background and cameras`,
shortDescription: t`Follow a character with scrolling background.`,
description: t`Follow this Castlevania-type character with the camera, while the background scrolls.`,
durationInMinutes: 2,
renderImage: props => <Parallax {...props} />,
},
{
id: TOP_DOWN_RPG_MOVEMENT_ID,
title: t`Top-Down RPG Pixel Perfect`,
shortDescription: t`Move like in retro Pokemon games.`,
description: t`Make a character move like in a retro Pokemon game.`,
durationInMinutes: 2,
renderImage: props => <TopDownRPGMovement {...props} />,
},
{
id: JOYSTICK_IN_APP_TUTORIAL_ID,
title: t`Joystick controls`,
shortDescription: t`Control a spaceship with a joystick.`,
description: t`Control your spaceship with a joystick, while avoiding asteroids.`,
durationInMinutes: 1,
renderImage: props => <Joystick {...props} />,
},
{
id: OBJECT_3D_IN_APP_TUTORIAL_ID,
title: t`3D platforms`,
shortDescription: t`Place 3D platforms in a 2D game.`,
description: t`Place 3D platforms in this 2D platformer, creating a path to the end.`,
durationInMinutes: 2,
renderImage: props => <Object3D {...props} />,
},
{
id: HEALTH_BAR_IN_APP_TUTORIAL_ID,
title: t`Health bar`,
shortDescription: t`Add a health bar for handle damage.`,
description: t`Add a health bar to this jumping character, losing health when hitting spikes.`,
durationInMinutes: 2,
renderImage: props => <HealthBar {...props} />,
},
{
id: TILEMAP_PLATFORMER_ID,
title: t`Paint a Level with Tiles`,
shortDescription: t`Build dynamic levels with tiles.`,
description: t`Use a Tilemap to build a level and change it dynamically during the game.`,
durationInMinutes: 2,
renderImage: props => <TilemapPlatformer {...props} />,
},
{
id: FIRE_A_BULLET_ID,
title: t`Fire a Bullet`,
shortDescription: t`Fire bullets in an Asteroids game.`,
description: t`Fire bullets in this Asteroids game. Get ready for a Star Wars show.`,
durationInMinutes: 2,
renderImage: props => <FireABullet {...props} />,
},
{
id: TIMER_IN_APP_TUTORIAL_ID,
title: t`Time score`,
shortDescription: t`Add a time attack mode.`,
description: t`Add a time attack mode, where you have to reach the end as fast as possible.`,
durationInMinutes: 2,
renderImage: props => <Timer {...props} />,
},
{
id: COOP_PLATFORMER_ID,
title: t`Co-op Multiplayer`,
shortDescription: t`Transform a game into a multiplayer experience.`,
description: t`Transform this platformer into a co-op game, where two players can play together.`,
durationInMinutes: 3,
renderImage: props => <CoopPlatformer {...props} />,
},
{
id: PLINKO_MULTIPLIER_IN_APP_TUTORIAL_ID,
title: t`Score multiplier`,
shortDescription: t`Multiply scores with collectibles.`,
description: t`Transform this Plinko game with collectibles that multiply your score.`,
durationInMinutes: 3,
renderImage: props => <MultiplierScore {...props} />,
},
].filter(item => displayedGuidedLessonsIds.includes(item.id));
[lessonsIds, displayAsCarousel, numberOfColumns]
);
return (
<Line>
@@ -241,30 +270,58 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
<ColoredLinearProgress value={lessonsProgress} />
</LineStackLayout>
</Column>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{guidedLessonCards.map(item => (
<GridListTile key={item.id}>
<InAppTutorialPhaseCard
title={item.title}
description={item.description}
shortDescription={item.shortDescription}
durationInMinutes={item.durationInMinutes}
renderImage={item.renderImage}
progress={getTutorialPartProgress({ tutorialId: item.id })}
onClick={() => selectInAppTutorial(item.id)}
// Phase is disabled if there's a running tutorial or if offline,
// because we cannot fetch the tutorial.
disabled={!!currentlyRunningInAppTutorial || !isOnline}
loading={!inAppTutorialShortHeaders}
/>
</GridListTile>
))}
</GridList>
{displayAsCarousel ? (
<Carousel
items={guidedLessonCards.map(item => ({
renderItem: () => (
<GridListTile key={item.id}>
<InAppTutorialPhaseCard
title={item.title}
description={item.description}
shortDescription={item.shortDescription}
durationInMinutes={item.durationInMinutes}
renderImage={item.renderImage}
progress={getTutorialPartProgress({
tutorialId: item.id,
})}
onClick={() => selectInAppTutorial(item.id)}
// Phase is disabled if there's a running tutorial or if offline,
// because we cannot fetch the tutorial.
disabled={!!currentlyRunningInAppTutorial || !isOnline}
loading={!inAppTutorialShortHeaders}
/>
</GridListTile>
),
}))}
/>
) : (
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{guidedLessonCards.map(item => (
<GridListTile key={item.id}>
<InAppTutorialPhaseCard
title={item.title}
description={item.description}
shortDescription={item.shortDescription}
durationInMinutes={item.durationInMinutes}
renderImage={item.renderImage}
progress={getTutorialPartProgress({
tutorialId: item.id,
})}
onClick={() => selectInAppTutorial(item.id)}
// Phase is disabled if there's a running tutorial or if offline,
// because we cannot fetch the tutorial.
disabled={!!currentlyRunningInAppTutorial || !isOnline}
loading={!inAppTutorialShortHeaders}
/>
</GridListTile>
))}
</GridList>
)}
</ColumnStackLayout>
)}
</div>

View File

@@ -15,6 +15,18 @@ import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { Trans } from '@lingui/macro';
const getChipColorFromDuration = (durationInMinutes: number) => {
if (durationInMinutes <= 2) return '#3BF7F4';
if (durationInMinutes <= 5) return '#FFBC57';
return '#FF8569';
};
const getChipTextFromDuration = (durationInMinutes: number) => {
if (durationInMinutes <= 2) return <Trans>Beginner</Trans>;
if (durationInMinutes <= 5) return <Trans>Intermediate</Trans>;
return <Trans>Advanced</Trans>;
};
const getImageSize = ({ isMobile }: { isMobile: boolean }) =>
isMobile ? 90 : 130;
@@ -23,7 +35,7 @@ const styles = {
flex: 1,
display: 'flex',
justifyContent: 'center',
padding: '10px 10px 15px 10px',
padding: '8px 16px',
},
lockerImage: { height: 80, width: 80 },
imageContainer: {
@@ -37,6 +49,9 @@ const styles = {
textAlign: 'left',
overflowWrap: 'anywhere',
},
chip: {
height: 24,
},
};
type Props = {|
@@ -118,87 +133,107 @@ const InAppTutorialPhaseCard = ({
}}
>
<ColumnStackLayout
noMargin
expand
justifyContent="flex-start"
justifyContent="space-between"
useFullHeight
noOverflowParent
>
{progress && progress > 0 ? (
progress !== 100 ? (
<LineStackLayout alignItems="center" noMargin>
<Text displayInlineAsSpan noMargin size="body2">
{progress}%
</Text>
<ColoredLinearProgress value={progress} />
</LineStackLayout>
) : (
<ColumnStackLayout
noMargin
expand
justifyContent="flex-start"
useFullHeight
noOverflowParent
>
<Text
size="sub-title"
noMargin
color="primary"
align="left"
style={getTextStyle(shouldTextBeDisabled)}
>
{i18n._(title)}
</Text>
<Text noMargin align="left" color="secondary">
{isMobile ? i18n._(shortDescription) : i18n._(description)}
</Text>
{keyPoints && <Divider />}
{keyPoints && (
<Column
noMargin
alignItems="flex-start"
justifyContent="flex-start"
expand
>
<ul style={styles.keyPointsList}>
{keyPoints.map((keyPoint, index) => (
<Text
key={`key-point-${index}`}
size="body2"
noMargin
style={getTextStyle(shouldTextBeDisabled)}
color="secondary"
displayAsListItem
>
{i18n._(keyPoint)}
</Text>
))}
</ul>
</Column>
)}
</ColumnStackLayout>
<Line justifyContent="space-between" alignItems="flex-end">
<Chip
style={{
...styles.chip,
border: `1px solid ${getChipColorFromDuration(
durationInMinutes || 0
)}`,
}}
label={getChipTextFromDuration(durationInMinutes || 0)}
variant="outlined"
/>
{progress && progress > 0 ? (
progress !== 100 ? (
<LineStackLayout alignItems="center" noMargin>
<Text displayInlineAsSpan noMargin size="body2">
{progress}%
</Text>
<ColoredLinearProgress value={progress} />
</LineStackLayout>
) : (
<Line noMargin justifyContent="center">
<Chip
size="small"
label={<Trans>Finished</Trans>}
style={{
backgroundColor:
gdevelopTheme.statusIndicator.success,
color: '#111111',
}}
/>
</Line>
)
) : durationInMinutes ? (
<Line noMargin justifyContent="center">
<Chip
size="small"
label={<Trans>Finished</Trans>}
style={{
backgroundColor:
gdevelopTheme.statusIndicator.success,
color: '#111111',
}}
label={
isMobile ? (
<Trans>{durationInMinutes} min.</Trans>
) : durationInMinutes === 1 ? (
<Trans>1 minute</Trans>
) : (
<Trans>{durationInMinutes} minutes</Trans>
)
}
/>
</Line>
)
) : durationInMinutes ? (
<Line noMargin justifyContent="center">
<Chip
size="small"
label={
durationInMinutes === 1 ? (
<Trans>1 minute</Trans>
) : (
<Trans>{durationInMinutes} minutes</Trans>
)
}
/>
</Line>
) : (
<Spacer />
)}
<Text
size="block-title"
noMargin
style={getTextStyle(shouldTextBeDisabled)}
color="primary"
>
{i18n._(title)}
</Text>
<Text
size="body"
noMargin
style={getTextStyle(shouldTextBeDisabled)}
color="secondary"
>
{isMobile ? i18n._(shortDescription) : i18n._(description)}
</Text>
{keyPoints && <Divider />}
{keyPoints && (
<Column
noMargin
alignItems="flex-start"
justifyContent="flex-start"
expand
>
<ul style={styles.keyPointsList}>
{keyPoints.map((keyPoint, index) => (
<Text
key={`key-point-${index}`}
size="body2"
noMargin
style={getTextStyle(shouldTextBeDisabled)}
color="secondary"
displayAsListItem
>
{i18n._(keyPoint)}
</Text>
))}
</ul>
</Column>
)}
) : (
<Spacer />
)}
</Line>
</ColumnStackLayout>
</div>
</Column>

View File

@@ -5,14 +5,24 @@ import { I18n } from '@lingui/react';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import Text from '../../../../UI/Text';
import { CardWidget } from '../CardWidget';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import { Column, Line } from '../../../../UI/Grid';
import ColoredLinearProgress from '../../../../UI/ColoredLinearProgress';
import { Trans } from '@lingui/macro';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { type CourseCompletion } from '../UseCourses';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import Skeleton from '@material-ui/lab/Skeleton';
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
import Chip from '../../../../UI/Chip';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
export const getChipColorFromEnglishLevel = (englishLevel: string) => {
if (englishLevel.toLowerCase().includes('advanced')) return '#FF8569';
if (englishLevel.toLowerCase().includes('intermediate')) return '#FFBC57';
return '#3BF7F4';
};
const styles = {
cardTextContainer: {
@@ -20,8 +30,11 @@ const styles = {
display: 'flex',
justifyContent: 'center',
padding: '8px 16px 8px 16px',
// Fix the height so that the card has a consistent height,
// When owned or not.
minHeight: 220,
},
image: { width: '100%' },
image: { width: '100%', aspectRatio: '16 / 9' },
specializationDot: {
width: 8,
height: 8,
@@ -39,7 +52,6 @@ const styles = {
alignItems: 'stretch',
aspectRatio: '16 / 9',
flexDirection: 'column',
flex: 1,
},
newLabel: {
position: 'absolute',
@@ -51,6 +63,9 @@ const styles = {
color: 'white',
padding: '2px 6px',
},
chip: {
height: 24,
},
};
const specializationLabels = {
@@ -64,10 +79,15 @@ const specializationColors = {
marketing: '#FD3AE6',
};
const getSpecializationConfig = (
export const getSpecializationConfig = (
specializationId: string
): {| label: React.Node, color: string |} => {
let label = specializationLabels[specializationId];
let label =
specializationId === 'loading' ? (
<Trans>Loading</Trans>
) : (
specializationLabels[specializationId]
);
if (!label) {
console.warn(
`No label found for specializationId "${specializationId}". Using default label.`
@@ -92,10 +112,17 @@ const getSpecializationConfig = (
type Props = {|
completion: ?CourseCompletion,
course: ?Course,
courseListingData: ?CourseListingData,
onClick?: () => void,
|};
const CourseCard = ({ completion, course, onClick }: Props) => {
const CourseCard = ({
completion,
course,
courseListingData,
onClick,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const specializationConfig = getSpecializationConfig(
course ? course.specializationId : 'loading'
);
@@ -103,7 +130,7 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
<I18n>
{({ i18n }) => (
<CardWidget onClick={onClick} size={'large'}>
{course && onClick ? (
{course && courseListingData && onClick ? (
<Column expand noMargin noOverflowParent>
<div style={styles.imageContainer}>
<img
@@ -151,25 +178,29 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
{specializationConfig.label}
</Text>
</LineStackLayout>
{completion && (
<LineStackLayout alignItems="center" noMargin>
<ColoredLinearProgress
value={
(completion.completedChapters /
completion.chapters) *
100
}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
>
{completion.completedChapters}/{completion.chapters}
</Text>
</LineStackLayout>
)}
<LineStackLayout alignItems="center" noMargin>
<ColoredLinearProgress
value={
completion
? (completion.completedChapters /
completion.chapters) *
100
: 0
}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
>
{completion
? `${completion.completedChapters}/${
completion.chapters
}`
: '-/-'}
</Text>
</LineStackLayout>
<Text
size="sub-title"
@@ -186,18 +217,31 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
)}
</Text>
</ColumnStackLayout>
<Line justifyContent="space-between">
<Text size="body-small" noMargin color="secondary">
{selectMessageByLocale(i18n, course.levelByLocale)}
</Text>
<Text size="body-small" noMargin color="secondary">
{course.durationInWeeks === 1 ? (
<Trans>1 week</Trans>
) : (
<Trans>{course.durationInWeeks} weeks</Trans>
)}
</Text>
</Line>
<div style={{ color: gdevelopTheme.text.color.secondary }}>
<Line justifyContent="space-between" alignItems="flex-end">
<Chip
style={{
...styles.chip,
border: `1px solid ${getChipColorFromEnglishLevel(
course.levelByLocale.en
)}`,
}}
label={selectMessageByLocale(
i18n,
course.levelByLocale
)}
variant="outlined"
/>
{getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData: courseListingData,
usageType: 'default',
showBothPrices: 'column',
owned: !course.isLocked,
})}
</Line>
</div>
</ColumnStackLayout>
</div>
</Column>
@@ -211,10 +255,8 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
<Skeleton height={20} />
<Skeleton height={20} />
<Skeleton height={30} />
<Skeleton height={100} />
<Skeleton height={50} />
<Spacer />
<Skeleton height={15} />
<Spacer />
</Column>
</Line>
</Column>

View File

@@ -0,0 +1,328 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import Dialog, { DialogPrimaryButton } from '../../../../UI/Dialog';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import CreateProfile from '../../../../Profile/CreateProfile';
import Text from '../../../../UI/Text';
import { useInterval } from '../../../../Utils/UseInterval';
import { getPurchaseCheckoutUrl } from '../../../../Utils/GDevelopServices/Shop';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import Window from '../../../../Utils/Window';
import { Line, Spacer } from '../../../../UI/Grid';
import CircularProgress from '../../../../UI/CircularProgress';
import BackgroundText from '../../../../UI/BackgroundText';
import Mark from '../../../../UI/CustomSvgIcons/Mark';
import FlatButton from '../../../../UI/FlatButton';
import { LineStackLayout } from '../../../../UI/Layout';
import useAlertDialog from '../../../../UI/Alert/useAlertDialog';
import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors';
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
type Props = {|
course: Course,
courseListingData: CourseListingData,
onClose: () => void,
|};
const CoursePurchaseDialog = ({
course,
courseListingData,
onClose,
}: Props) => {
const {
profile,
onOpenLoginDialog,
onOpenCreateAccountDialog,
onPurchaseSuccessful,
onRefreshCoursePurchases,
coursePurchases,
loginState,
} = React.useContext(AuthenticatedUserContext);
const [isPurchasing, setIsPurchasing] = React.useState(false);
const [
isCheckingPurchasesAfterLogin,
setIsCheckingPurchasesAfterLogin,
] = React.useState(loginState === 'loggingIn');
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const { showAlert } = useAlertDialog();
const onStartPurchase = async () => {
if (!profile) return;
setDisplayPasswordPrompt(false);
// Note: we don't handle purchasing a course through the App Store for now.
const price = courseListingData.prices.find(
price => price.usageType === 'default'
);
if (!price) {
console.error('Unable to find the price for the usage type default');
await showAlert({
title: t`An error happened`,
message: t`Unable to find the price for this course. Please try again later.`,
});
return;
}
try {
setIsPurchasing(true);
const checkoutUrl = getPurchaseCheckoutUrl({
productId: courseListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
});
Window.openExternalURL(checkoutUrl);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 403 &&
extractedStatusAndCode.code === 'auth/wrong-password'
) {
await showAlert({
title: t`Operation not allowed`,
message: t`The password you entered is incorrect. Please try again.`,
});
} else {
console.error('Unable to get the checkout URL', error);
await showAlert({
title: t`An error happened`,
message: t`Unable to get the checkout URL. Please try again later.`,
});
}
setIsPurchasing(false);
} finally {
setPassword('');
}
};
const onWillPurchase = () => {
// Password is required in dev environment only so that one cannot freely purchase courses.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onStartPurchase();
};
React.useEffect(
() => {
onWillPurchase();
},
// Launch the start process directly when the dialog is opened, to avoid an extra click.
// eslint-disable-next-line
[]
);
React.useEffect(
() => {
const checkIfPurchaseIsDone = async () => {
if (
isPurchasing &&
coursePurchases &&
coursePurchases.find(
userPurchase => userPurchase.productId === courseListingData.id
)
) {
// We found the purchase, the user has bought the course.
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
await onPurchaseSuccessful();
}
};
checkIfPurchaseIsDone();
},
[
isPurchasing,
coursePurchases,
courseListingData,
onPurchaseSuccessful,
onRefreshCoursePurchases,
]
);
useInterval(
() => {
onRefreshCoursePurchases();
},
isPurchasing ? 3900 : null
);
// Listen to the login state, to know when a user has just logged in and the courses are being fetched.
// In this case, start a timeout to remove the loader and give some time for the courses to refresh.
React.useEffect(
() => {
let timeoutId;
(async () => {
if (loginState === 'done') {
timeoutId = setTimeout(
() => setIsCheckingPurchasesAfterLogin(false),
3000
);
}
})();
return () => {
clearTimeout(timeoutId);
};
},
[loginState]
);
// If the user has received this particular course, either:
// - they just logged in, and already have it, so we close the dialog.
// - they just bought it, we display the success message.
React.useEffect(
() => {
if (course) {
if (!course.isLocked) {
if (isPurchasing) {
setIsPurchasing(false);
setPurchaseSuccessful(true);
} else if (!purchaseSuccessful) {
onClose();
}
}
}
},
[course, courseListingData, isPurchasing, onClose, purchaseSuccessful]
);
const dialogContents = !profile
? {
subtitle: <Trans>Log-in to purchase this course</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
Courses will be linked to your user account and available
indefinitely. Log in or sign up to purchase this course or
restore a previous purchase.
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>You can now go back to the course.</Trans>
</Text>
</Line>
),
}
: isPurchasing
? {
subtitle: <Trans>Complete your payment on the web browser</Trans>,
content: (
<>
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
<Spacer />
<Text>
<Trans>Waiting for the purchase confirmation...</Trans>
</Text>
</Line>
<Spacer />
<Line justifyContent="center">
<BackgroundText>
<Trans>
Once you're done, come back to GDevelop and the assets will be
added to your account automatically.
</Trans>
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
</Line>
),
}
: {
subtitle: (
<Trans>
The course {courseListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
),
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
</Text>
</Line>
),
};
const allowPurchase =
profile &&
!isPurchasing &&
!purchaseSuccessful &&
!isCheckingPurchasesAfterLogin;
const dialogActions = [
<FlatButton
key="cancel"
label={purchaseSuccessful ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
onClick={onClose}
/>,
allowPurchase ? (
<DialogPrimaryButton
key="continue"
primary
label={<Trans>Continue</Trans>}
onClick={onWillPurchase}
/>
) : null,
];
return (
<>
<Dialog
title={<Trans>{courseListingData.name}</Trans>}
maxWidth="sm"
open
onRequestClose={onClose}
actions={dialogActions}
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}
<Text size="sub-title">{dialogContents.subtitle}</Text>
</LineStackLayout>
{dialogContents.content}
</Dialog>
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onStartPurchase}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</>
);
};
export default CoursePurchaseDialog;

View File

@@ -1,12 +1,14 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import {
type CourseChapter,
type Course,
} from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import SectionContainer from '../SectionContainer';
import VideoBasedCourseChapterView from '../../../../Course/VideoBasedCourseChapterView';
import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapterView';
@@ -32,8 +34,7 @@ import {
AccordionBody,
AccordionHeader,
} from '../../../../UI/Accordion';
import AnyQuestionDialog from '../AnyQuestionDialog';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import CourseSectionHeader from './CourseSectionHeader';
const styles = {
desktopContainer: { display: 'flex', gap: 16 },
@@ -101,7 +102,20 @@ type Props = {|
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
getCourseCompletion: () => CourseCompletion | null,
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
onBuyCourseWithCredits: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
onBuyCourse: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
simulateAppStoreProduct?: boolean,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|};
const CourseSection = ({
@@ -113,15 +127,17 @@ const CourseSection = ({
isTaskCompleted,
getChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
simulateAppStoreProduct,
onOpenAskAi,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { showAlertMessage, values } = React.useContext(PreferencesContext);
const { isMobile, isLandscape } = useResponsiveWindowSize();
const courseCompletion = getCourseCompletion();
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
false
);
const firstIncompleteChapterIdRef = React.useRef<string | null>(
courseChapters.reduce((alreadyFoundIncompleteChapterId, chapter, index) => {
if (alreadyFoundIncompleteChapterId)
@@ -275,6 +291,19 @@ const CourseSection = ({
[scrollToChapter]
);
const onClickUnlock = React.useCallback(
() => {
// Scroll to the top of the page, where the purchase button is.
if (scrollingContainerRef.current) {
scrollingContainerRef.current.scrollTo({
top: 0,
behavior: 'smooth',
});
}
},
[scrollingContainerRef]
);
return (
<I18n>
{({ i18n }) => (
@@ -283,11 +312,6 @@ const CourseSection = ({
ref={scrollingContainerRef}
applyTopSpacingAsMarginOnChildrenContainer
backAction={onBack}
title={selectMessageByLocale(i18n, course.titleByLocale)}
subtitleText={selectMessageByLocale(
i18n,
course.shortDescriptionByLocale
)}
>
<div
style={
@@ -295,6 +319,16 @@ const CourseSection = ({
}
>
<Column noOverflowParent noMargin>
<CourseSectionHeader
course={course}
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
purchasingCourseListingData={purchasingCourseListingData}
setPurchasingCourseListingData={
setPurchasingCourseListingData
}
simulateAppStoreProduct={simulateAppStoreProduct}
/>
{!values.hiddenAlertMessages[alertMessageKey] && subtitleHint && (
<Line>
<AlertMessage
@@ -310,6 +344,7 @@ const CourseSection = ({
chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
course={course}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
@@ -318,7 +353,7 @@ const CourseSection = ({
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
@@ -331,6 +366,7 @@ const CourseSection = ({
) : (
<TextBasedCourseChapterView
chapterIndex={index}
course={course}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
@@ -340,7 +376,7 @@ const CourseSection = ({
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
@@ -394,9 +430,9 @@ const CourseSection = ({
</Text>
</LineStackLayout>
<RaisedButton
label={<Trans>Ask a question</Trans>}
primary
onClick={() => setIsAnyQuestionDialogOpen(true)}
label={<Trans>Ask the AI</Trans>}
onClick={() => onOpenAskAi('chat')}
/>
</ColumnStackLayout>
</Paper>
@@ -405,11 +441,6 @@ const CourseSection = ({
)}
</div>
</SectionContainer>
{isAnyQuestionDialogOpen && (
<AnyQuestionDialog
onClose={() => setIsAnyQuestionDialogOpen(false)}
/>
)}
{isMobile && !isLandscape && (
<div
style={{

View File

@@ -0,0 +1,316 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import { SectionRow } from '../SectionContainer';
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import { Column, Line } from '../../../../UI/Grid';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import CoursePurchaseDialog from './CoursePurchaseDialog';
import {
getChipColorFromEnglishLevel,
getSpecializationConfig,
} from './CourseCard';
import { renderProductPrice } from '../../../../AssetStore/ProductPriceTag';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
import Window from '../../../../Utils/Window';
import { PurchaseProductButtons } from '../../../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
import { Divider } from '@material-ui/core';
import classes from './CourseSectionHeader.module.css';
import Gold from '../../../../Profile/Subscription/Icons/Gold';
import Chip from '../../../../UI/Chip';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
image: { width: 300, aspectRatio: '16 / 9' },
specializationDot: {
width: 8,
height: 8,
borderRadius: '50%',
flexShrink: 0,
},
imageContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
paper: {
zIndex: 2, // Make sure the paper is above the background for the border effect.
flex: 1,
},
diamondIcon: {
width: 20,
height: 20,
},
chip: {
height: 24,
},
};
const ResponsiveDivider = () => {
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
return isMobile || isMediumScreen ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
);
};
type Props = {|
course: Course,
onBuyCourseWithCredits: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
onBuyCourse: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
simulateAppStoreProduct?: boolean,
|};
const CourseSectionHeader = ({
course,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
simulateAppStoreProduct,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const onClickBuyWithCredits = React.useCallback(
async (i18n: I18nType) => {
if (!course.isLocked) return;
setDisplayPasswordPrompt(false);
await onBuyCourseWithCredits(course, password, i18n);
},
[course, onBuyCourseWithCredits, password]
);
const onWillBuyWithCredits = React.useCallback(
async (i18n: I18nType) => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onClickBuyWithCredits(i18n);
},
[onClickBuyWithCredits]
);
const specializationConfig = getSpecializationConfig(
course ? course.specializationId : 'loading'
);
const courseListingData = listedCourses
? listedCourses.find(listedCourse => listedCourse.id === course.id)
: null;
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
return (
<I18n>
{({ i18n }) => (
<>
<SectionRow>
<Paper background="dark" variant="outlined" style={{ padding: 16 }}>
<ColumnStackLayout noMargin>
<ResponsiveLineStackLayout
noMargin
alignItems="center"
justifyContent="flex-start"
forceMobileLayout={isMediumScreen}
expand
>
<div style={styles.imageContainer}>
<img
src={selectMessageByLocale(i18n, course.imageUrlByLocale)}
style={styles.image}
alt=""
/>
</div>
<ColumnStackLayout expand justifyContent="flex-start">
{course.includedInSubscriptions.length && (
<Line
noMargin
alignItems="center"
justifyContent={
isMobile || isMediumScreen ? 'flex-start' : 'flex-end'
}
>
<div className={classes.premiumContainer}>
<Paper style={styles.paper} background="medium">
<Column>
<Line expand alignItems="center" noMargin>
<Gold style={styles.diamondIcon} />
<Text>
<Trans>
Included with GDevelop subscriptions
</Trans>
</Text>
</Line>
</Column>
</Paper>
</div>
</Line>
)}
<Text size="title" noMargin style={styles.title}>
{selectMessageByLocale(i18n, course.titleByLocale)}
</Text>
<Line noMargin>
<Text noMargin>
{selectMessageByLocale(
i18n,
course.shortDescriptionByLocale
)}
</Text>
</Line>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout
expand
justifyContent="space-between"
forceMobileLayout={isMediumScreen}
>
<Column justifyContent="center" expand noMargin>
<Text>
<Trans>{course.chaptersTargetCount} chapters</Trans>
</Text>
</Column>
<ResponsiveDivider />
<Column
justifyContent="center"
alignItems="flex-start"
expand
noMargin
>
<Chip
style={{
...styles.chip,
border: `1px solid ${getChipColorFromEnglishLevel(
course.levelByLocale.en
)}`,
}}
label={selectMessageByLocale(i18n, course.levelByLocale)}
variant="outlined"
/>
</Column>
<ResponsiveDivider />
<Column justifyContent="center" expand noMargin>
<LineStackLayout alignItems="center" noMargin>
<span
style={{
...styles.specializationDot,
backgroundColor: specializationConfig.color,
}}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
style={textEllipsisStyle}
>
{specializationConfig.label}
</Text>
</LineStackLayout>
</Column>
</ResponsiveLineStackLayout>
{course.isLocked && (
<Paper background="medium" style={{ padding: 16 }}>
{!!courseListingData && (
<ResponsiveLineStackLayout
justifyContent="space-between"
noMargin
>
{!isMobile && !isMediumScreen && (
<Column noMargin justifyContent="center">
<div
style={{
color: gdevelopTheme.text.color.secondary,
}}
>
{renderProductPrice({
i18n,
productListingData: courseListingData,
usageType: 'default',
showBothPrices: 'line',
})}
</div>
</Column>
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={courseListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={
shouldUseOrSimulateAppStoreProduct
}
isAlreadyReceived={!course.isLocked}
onClickBuy={() => onBuyCourse(course, password, i18n)}
onClickBuyWithCredits={() =>
onWillBuyWithCredits(i18n)
}
/>
</ResponsiveLineStackLayout>
)}
</Paper>
)}
</ColumnStackLayout>
</Paper>
</SectionRow>
{!!purchasingCourseListingData && (
<CoursePurchaseDialog
course={course}
courseListingData={purchasingCourseListingData}
onClose={() => setPurchasingCourseListingData(null)}
/>
)}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={() => onClickBuyWithCredits(i18n)}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</>
)}
</I18n>
);
};
export default CourseSectionHeader;

View File

@@ -0,0 +1,20 @@
.premiumContainer {
position: relative;
overflow: hidden;
padding: 2px;
border-radius: 10px;
display: flex;
flex-shrink: 0;
}
.premiumContainer::before {
content: '';
display: block;
background: linear-gradient(90deg, var(--theme-premium-teal) 0%, var(--theme-premium-orange) 100%);
width: 100%;
padding-bottom: 100%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}

View File

@@ -0,0 +1,122 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import { Trans } from '@lingui/macro';
import {
type Course,
type CourseChapter,
} from '../../../../Utils/GDevelopServices/Asset';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import { Line } from '../../../../UI/Grid';
import { GridList, GridListTile } from '@material-ui/core';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { LARGE_WIDGET_SIZE } from '../CardWidget';
import CourseCard from './CourseCard';
import { getColumnsFromWindowSize } from './Utils';
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
const styles = {
grid: {
textAlign: 'center',
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
};
type Props = {|
onBack: () => void,
courses: ?Array<Course>,
onSelectCourse: (courseId: string) => void,
previewedCourse: ?Course,
previewedCourseChapters: ?Array<CourseChapter>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
|};
const CoursesPage = ({
onBack,
courses,
onSelectCourse,
previewedCourse,
previewedCourseChapters,
getCourseChapterCompletion,
getCourseCompletion,
}: Props) => {
const { listedCourses } = React.useContext(CourseStoreContext);
const { windowSize, isLandscape } = useResponsiveWindowSize();
return (
<I18n>
{({ i18n }) => (
<SectionContainer
title={<Trans>Official Game Dev courses</Trans>}
backAction={onBack}
>
<SectionRow>
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
if (!previewedCourse) return;
onSelectCourse(previewedCourse.id);
}}
/>
</SectionRow>
<SectionRow>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses && listedCourses
? courses.map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
);
})
: new Array(6).fill(0).map((_, index) => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard
course={null}
courseListingData={null}
completion={null}
/>
</GridListTile>
))}
</GridList>
</Line>
</SectionRow>
</SectionContainer>
)}
</I18n>
);
};
export default CoursesPage;

View File

@@ -0,0 +1,62 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import Text from '../../../../UI/Text';
import { LargeSpacer, Line } from '../../../../UI/Grid';
import { Trans } from '@lingui/macro';
import PreferencesContext from '../../../Preferences/PreferencesContext';
import FlatButton from '../../../../UI/FlatButton';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
import GuidedLessons from '../InAppTutorials/GuidedLessons';
import FlingGame from '../InAppTutorials/FlingGame';
type Props = {|
onBack: () => void,
selectInAppTutorial: (tutorialId: string) => void,
|};
const InAppTutorialsPage = ({ onBack, selectInAppTutorial }: Props) => {
const {
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
return (
<I18n>
{({ i18n }) => (
<SectionContainer backAction={onBack}>
<SectionRow>
<Line justifyContent="space-between" noMargin alignItems="center">
<Text noMargin size="section-title">
<Trans>In-app Tutorials</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
/>
)}
</Line>
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<SectionRow>
<Text size="section-title">
<Trans>Create and Publish a Fling game</Trans>
</Text>
<Text size="body" color="secondary" noMargin>
<Trans>
3-part tutorial to creating and publishing a game from scratch.
</Trans>
</Text>
<LargeSpacer />
<FlingGame selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
</SectionContainer>
)}
</I18n>
);
};
export default InAppTutorialsPage;

View File

@@ -8,10 +8,7 @@ import { Trans } from '@lingui/macro';
import TranslateIcon from '@material-ui/icons/Translate';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import { type HomeTab } from '../HomePageMenu';
import {
type TutorialCategory,
type Tutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import SectionContainer, { SectionRow } from '../SectionContainer';
import type {
Course,
@@ -19,51 +16,30 @@ import type {
} from '../../../../Utils/GDevelopServices/Asset';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import FlatButton from '../../../../UI/FlatButton';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { CardWidget, LARGE_WIDGET_SIZE } from '../CardWidget';
import GridList from '@material-ui/core/GridList';
import GridListTile from '@material-ui/core/GridListTile';
import ImageTileRow from '../../../../UI/ImageTileRow';
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
import GuidedLessons from '../InAppTutorials/GuidedLessons';
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
import Upload from '../../../../UI/CustomSvgIcons/Upload';
import FlingGame from '../InAppTutorials/FlingGame';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import { EducationCard } from './EducationCard';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
import PreferencesContext from '../../../Preferences/PreferencesContext';
import RaisedButton from '../../../../UI/RaisedButton';
import Help from '../../../../UI/CustomSvgIcons/Help';
import AnyQuestionDialog from '../AnyQuestionDialog';
import Paper from '../../../../UI/Paper';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import CourseCard from './CourseCard';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import Link from '../../../../UI/Link';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import TutorialsRow from './TutorialsRow';
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
import ExampleStore from '../../../../AssetStore/ExampleStore';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import Carousel from '../../../../UI/Carousel';
const getColumnsFromWindowSize = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
@@ -82,83 +58,30 @@ const styles = {
},
};
const getTutorialsColumnsFromWidth = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 5 : 2;
case 'medium':
return 3;
case 'large':
return 5;
case 'xlarge':
return 6;
default:
return 3;
}
};
type TutorialsRowProps = {|
limits: ?Limits,
tutorials: Tutorial[],
category: TutorialCategory,
onSelectCategory: (TutorialCategory | null) => void,
onSelectTutorial: (tutorial: Tutorial) => void,
|};
export const TutorialsRow = ({
limits,
tutorials,
category,
onSelectCategory,
onSelectTutorial,
}: TutorialsRowProps) => (
<I18n>
{({ i18n }) => (
<ImageTileRow
title={TUTORIAL_CATEGORY_TEXTS[category].title}
description={TUTORIAL_CATEGORY_TEXTS[category].description}
items={tutorials
.filter(tutorial => tutorial.category === category)
.map(tutorial =>
formatTutorialToImageTileComponent({
i18n,
limits,
tutorial,
onSelectTutorial,
})
)}
onShowAll={() => onSelectCategory(category)}
showAllIcon={<ArrowRight fontSize="small" />}
getColumnsFromWindowSize={getTutorialsColumnsFromWidth}
getLimitFromWindowSize={getTutorialsColumnsFromWidth}
/>
)}
</I18n>
);
type Props = {|
onTabChange: (tab: HomeTab) => void,
onSelectCategory: (TutorialCategory | null) => void,
tutorials: Array<Tutorial>,
onSelectCategory: (category: LearnCategory) => void,
selectInAppTutorial: (tutorialId: string) => void,
previewedCourse: ?Course,
courses: ?(Course[]),
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string | null) => void,
onSelectCourse: (courseId: string) => void,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|};
const MainPage = ({
onTabChange,
onSelectCategory,
tutorials,
selectInAppTutorial,
previewedCourse,
courses,
@@ -166,21 +89,17 @@ const MainPage = ({
onSelectCourse,
getCourseCompletion,
getCourseChapterCompletion,
onOpenAskAi,
onOpenNewProjectSetupDialog,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
}: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
const {
palette: { type: paletteType },
} = React.useContext(GDevelopThemeContext);
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
false
);
const {
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const {
windowSize,
isMobile,
@@ -188,6 +107,17 @@ const MainPage = ({
isMediumScreen,
} = useResponsiveWindowSize();
const displayedCourses = React.useMemo(
() => {
if (!courses) return null;
const numberOfColumnsToScroll =
getColumnsFromWindowSize(windowSize, isLandscape) *
(NUMBER_OF_SCROLLS + 1);
return courses.slice(0, numberOfColumnsToScroll);
},
[courses, windowSize, isLandscape]
);
const [
selectedTutorial,
setSelectedTutorial,
@@ -217,312 +147,296 @@ const MainPage = ({
},
].filter(Boolean);
return (
<SectionContainer
title={<Trans>Your learning journey starts here</Trans>}
customPaperStyle={{
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'top',
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
paletteType === 'dark'
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
}`,
}}
>
<SectionRow>
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
if (!previewedCourse) return;
onSelectCourse(previewedCourse.id);
onSelectCategory('course');
}}
/>
</SectionRow>
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
<SectionRow>
<Text size="title">
<Trans>GameDev official specialization courses</Trans>
</Text>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses
? courses.map(course => {
const completion = getCourseCompletion(course.id);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
onSelectCategory('course');
}}
/>
</GridListTile>
);
})
: new Array(5).fill(0).map((_, index) => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard course={null} completion={null} />
return (
<I18n>
{({ i18n }) => (
<SectionContainer
chipText={<Trans>Start for free</Trans>}
title={<Trans>Official Game Dev courses</Trans>}
titleAdornment={
<Line noMargin justifyContent="flex-end">
<FlatButton
onClick={() => onSelectCategory('all-courses')}
label={<Trans>See all</Trans>}
rightIcon={<ArrowRight fontSize="small" />}
/>
</Line>
}
subtitleText={
<Trans>
Break into the{' '}
<Link
href={'https://gdevelop.io/blog/indie-mobile-creators-2025'}
onClick={() =>
Window.openExternalURL(
'https://gdevelop.io/blog/indie-mobile-creators-2025'
)
}
>
booming industry
</Link>{' '}
of casual gaming. Sharpen your skills and become a professional.
Start for free:
</Trans>
}
customPaperStyle={{
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'top',
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
paletteType === 'dark'
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
}`,
}}
>
<SectionRow>
<Line>
<Carousel
items={
displayedCourses && listedCourses
? displayedCourses.map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return {
renderItem: () => (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
),
};
})
: new Array(6).fill(0).map((_, index) => ({
renderItem: () => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard
course={null}
courseListingData={null}
completion={null}
/>
</GridListTile>
),
}))
}
/>
</Line>
</SectionRow>
<SectionRow>
<LineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<Text size="section-title">
<Trans>In-app tutorials</Trans>
</Text>
</Column>
<Column noMargin>
<FlatButton
onClick={() => onSelectCategory('in-app-tutorials')}
label={<Trans>See all</Trans>}
rightIcon={<ArrowRight fontSize="small" />}
/>
</Column>
</LineStackLayout>
<GuidedLessons
selectInAppTutorial={selectInAppTutorial}
displayAsCarousel
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="all-tutorials"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
>
<Column noMargin expand>
<Text size="section-title">
<Trans>Learn by dissecting ready-made games</Trans>
</Text>
</Column>
<Column noMargin expand>
<Line noMargin justifyContent="flex-end">
<FlatButton
onClick={onOpenNewProjectSetupDialog}
label={<Trans>See all</Trans>}
rightIcon={<ArrowRight fontSize="small" />}
/>
</Line>
</Column>
</LineStackLayout>
<Spacer />
<ExampleStore
onSelectExampleShortHeader={onSelectExampleShortHeader}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
i18n={i18n}
getColumnsFromWindowSize={getColumnsFromWindowSize}
hideSearch
onlyShowGames
hidePremiumTemplates
limitRowsTo={1}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="full-game"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<LineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<Text size="section-title">
<Trans>Want to know more?</Trans>
</Text>
</Column>
<LineStackLayout noMargin>
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
);
}}
primary
leftIcon={<Upload />}
label={
isMediumScreen ? (
<Trans>Submit an example</Trans>
) : (
<Trans>Submit your project as an example</Trans>
)
}
/>
)}
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://airtable.com/shrv295oHlsuS69el'
);
}}
primary
leftIcon={<TranslateIcon />}
label={
isMediumScreen ? (
<Trans>Submit a tutorial</Trans>
) : (
<Trans>
Submit a tutorial translated in your language
</Trans>
)
}
/>
)}
</LineStackLayout>
</LineStackLayout>
</SectionRow>
<SectionRow>
<ColumnStackLayout noMargin expand>
<Line noMargin>
<GridList
cols={numberOfColumns}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
<GridListTile cols={1} style={{ background: 'transparent' }}>
<Paper
background="light"
style={{ display: 'flex', height: '100%' }}
>
<Column expand>
<Line expand alignItems="flex-start">
<Help />
<ColumnStackLayout expand alignItems="flex-start">
<Text noMargin size="block-title" align="left">
<Trans>Blocked on GDevelop?</Trans>
</Text>
<RaisedButton
size="large"
color="success"
label={<Trans>Ask the AI</Trans>}
rightIcon={<ArrowRight />}
onClick={() => onOpenAskAi('chat')}
/>
</ColumnStackLayout>
</Line>
</Column>
</Paper>
</GridListTile>
))}
</GridList>
</Line>
</SectionRow>
<SectionRow>
<Line justifyContent="space-between" noMargin alignItems="center">
<Text noMargin size="title">
<Trans>Guided lessons</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
{helpItems.map((helpItem, index) => (
<GridListTile key={index}>
<CardWidget
onClick={helpItem.action}
key={index}
size="large"
disabled={helpItem.disabled}
useDefaultDisabledStyle
>
<div style={styles.helpItem}>
<ColumnStackLayout
expand
justifyContent="center"
useFullHeight
>
<Text noMargin size="block-title">
{helpItem.title}
</Text>
<Text noMargin size="body" color="secondary">
{helpItem.description}
</Text>
</ColumnStackLayout>
</div>
</CardWidget>
</GridListTile>
))}
</GridList>
</Line>
</ColumnStackLayout>
</SectionRow>
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</Line>
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<SectionRow>
<ColumnStackLayout noMargin expand>
<Line noMargin>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{limits &&
limits.quotas['ask-question'] &&
limits.quotas['ask-question'].max > 0 ? (
<GridListTile cols={2} style={{ background: 'transparent' }}>
<Paper
background="light"
style={{ display: 'flex', height: '100%' }}
>
<Column expand>
<Line expand alignItems="flex-start">
<Help />
<ColumnStackLayout expand alignItems="flex-start">
<Text noMargin size="block-title">
<Trans>Blocked on GDevelop?</Trans>
</Text>
<RaisedButton
label={
<Trans>Ask any question, get an answer</Trans>
}
size="medium"
color="success"
onClick={() => {
setIsAnyQuestionDialogOpen(true);
}}
/>
</ColumnStackLayout>
</Line>
</Column>
</Paper>
</GridListTile>
) : null}
{helpItems.map((helpItem, index) => (
<GridListTile key={index}>
<CardWidget
onClick={helpItem.action}
key={index}
size="large"
disabled={helpItem.disabled}
useDefaultDisabledStyle
>
<div style={styles.helpItem}>
<ColumnStackLayout
expand
justifyContent="center"
useFullHeight
>
<Text noMargin size="block-title">
{helpItem.title}
</Text>
<Text noMargin size="body" color="secondary">
{helpItem.description}
</Text>
</ColumnStackLayout>
</div>
</CardWidget>
</GridListTile>
))}
</GridList>
</Line>
</ColumnStackLayout>
</SectionRow>
<>
<SectionRow>
<Line noMargin>
<Text size="title">
<Trans>Courses</Trans>
</Text>
</Line>
<Line noMargin>
<Text noMargin>
<Trans>Learn everything about GDevelop from the ground up</Trans>
</Text>
</Line>
{limits &&
limits.capabilities.classrooms &&
limits.capabilities.classrooms.hideUpgradeNotice ? null : (
<>
<Spacer />
<EducationCard
onSeeResources={() => onSelectCategory('education-curriculum')}
/>
</>
)}
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-beginner"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-intermediate"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-advanced"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<Text noMargin size="section-title">
<Trans>Create and Publish a Fling game</Trans>
</Text>
<Text size="body" color="secondary" noMargin>
<Trans>
3-part tutorial to creating and publishing a game from scratch.
</Trans>
</Text>
<FlingGame selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<SectionRow>
<LineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<Text size="title">
<Trans>Guides and tutorials</Trans>
</Text>
</Column>
<LineStackLayout noMargin>
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
);
}}
primary
leftIcon={<Upload />}
label={
isMediumScreen ? (
<Trans>Submit an example</Trans>
) : (
<Trans>Submit your project as an example</Trans>
)
}
/>
)}
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://airtable.com/shrv295oHlsuS69el'
);
}}
primary
leftIcon={<TranslateIcon />}
label={
isMediumScreen ? (
<Trans>Submit a tutorial</Trans>
) : (
<Trans>
Submit a tutorial translated in your language
</Trans>
)
}
/>
)}
</LineStackLayout>
</LineStackLayout>
<Line noMargin>
<Text noMargin>
<Trans>Learn by doing</Trans>
</Text>
</Line>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="education-curriculum"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="full-game"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="game-mechanic"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</>
{isAnyQuestionDialogOpen && (
<AnyQuestionDialog onClose={() => setIsAnyQuestionDialogOpen(false)} />
</SectionContainer>
)}
</SectionContainer>
</I18n>
);
};

View File

@@ -8,7 +8,10 @@ import {
type Tutorial,
type TutorialCategory,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
import {
formatTutorialToImageTileComponent,
TUTORIAL_CATEGORY_TEXTS,
} from './Utils';
import ImageTileGrid from '../../../../UI/ImageTileGrid';
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
@@ -16,6 +19,8 @@ import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutoria
import EducationCurriculumLesson from './EducationCurriculumLesson';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import Text from '../../../../UI/Text';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
const styles = {
educationCurriculumTutorialContainer: {
@@ -126,7 +131,7 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
case 'medium':
return 3;
case 'large':
return 5;
return 4;
case 'xlarge':
return 6;
default:
@@ -136,7 +141,6 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
type Props = {|
onBack: () => void,
tutorials: Array<Tutorial>,
category: TutorialCategory,
onOpenTemplateFromTutorial: string => Promise<void>,
onSelectCourse: (courseId: string) => void,
@@ -144,22 +148,32 @@ type Props = {|
const TutorialsCategoryPage = ({
category,
tutorials,
onBack,
onOpenTemplateFromTutorial,
onSelectCourse,
}: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const { tutorials } = React.useContext(TutorialContext);
const texts = TUTORIAL_CATEGORY_TEXTS[category];
const filteredTutorials = tutorials.filter(
tutorial => tutorial.category === category
);
const filteredTutorials = tutorials
? tutorials.filter(tutorial => tutorial.category === category)
: null;
const [
selectedTutorial,
setSelectedTutorial,
] = React.useState<Tutorial | null>(null);
if (!filteredTutorials) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<I18n>
{({ i18n }) => (

View File

@@ -0,0 +1,92 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import TutorialsRow from './TutorialsRow';
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
type Props = {|
onSelectCategory: (category: LearnCategory) => void,
|};
const TutorialsPage = ({ onSelectCategory }: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const [
selectedTutorial,
setSelectedTutorial,
] = React.useState<Tutorial | null>(null);
return (
<I18n>
{({ i18n }) => (
<SectionContainer backAction={() => onSelectCategory(null)}>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-beginner"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-intermediate"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-advanced"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="education-curriculum"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="full-game"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="game-mechanic"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</SectionContainer>
)}
</I18n>
);
};
export default TutorialsPage;

View File

@@ -0,0 +1,130 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { I18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import {
type TutorialCategory,
type Tutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import ImageTileRow from '../../../../UI/ImageTileRow';
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import {
formatTutorialToImageTileComponent,
TUTORIAL_CATEGORY_TEXTS,
type LearnCategory,
} from './Utils';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import Paper from '../../../../UI/Paper';
import PlaceholderError from '../../../../UI/PlaceholderError';
const styles = {
paper: {
flex: 1,
display: 'flex',
},
};
type TutorialsRowProps = {|
limits: ?Limits,
category: TutorialCategory | 'all-tutorials',
onSelectCategory: (category: LearnCategory) => void,
onSelectTutorial: (tutorial: Tutorial) => void,
getColumnsFromWindowSize: (
windowSize: WindowSizeType,
isLandscape: boolean
) => number,
|};
export const TutorialsRow = ({
limits,
category,
onSelectCategory,
onSelectTutorial,
getColumnsFromWindowSize,
}: TutorialsRowProps) => {
const {
tutorials,
error: tutorialLoadingError,
fetchTutorials,
} = React.useContext(TutorialContext);
const title =
category === 'all-tutorials' ? (
<Trans>Public tutorials</Trans>
) : (
TUTORIAL_CATEGORY_TEXTS[category].title
);
const description =
category === 'all-tutorials'
? null
: TUTORIAL_CATEGORY_TEXTS[category].description;
const getItems = (i18n: I18nType) => {
if (!tutorials) return [];
let filteredTutorials = tutorials;
// If category is all, show 2 of each official category.
if (category === 'all-tutorials') {
const officialBeginnerTutorials = tutorials
.filter(tutorial => tutorial.category === 'official-beginner')
.slice(0, 2);
const officialIntermediateTutorials = tutorials
.filter(tutorial => tutorial.category === 'official-intermediate')
.slice(0, 2);
const officialAdvancedTutorials = tutorials
.filter(tutorial => tutorial.category === 'official-advanced')
.slice(0, 2);
filteredTutorials = [
...officialBeginnerTutorials,
...officialIntermediateTutorials,
...officialAdvancedTutorials,
];
} else {
filteredTutorials = tutorials.filter(
tutorial => tutorial.category === category
);
}
return filteredTutorials.map(tutorial =>
formatTutorialToImageTileComponent({
i18n,
limits,
tutorial,
onSelectTutorial,
})
);
};
if (tutorialLoadingError) {
return (
<Paper square style={styles.paper} background="dark">
<PlaceholderError onRetry={fetchTutorials}>
<Trans>
Can't load the tutorials. Verify your internet connection or retry
later.
</Trans>
</PlaceholderError>
</Paper>
);
}
return (
<I18n>
{({ i18n }) => (
<ImageTileRow
title={title}
description={description}
items={getItems(i18n)}
isLoading={!tutorials}
onShowAll={() => onSelectCategory(category)}
showAllIcon={<ArrowRight fontSize="small" />}
getColumnsFromWindowSize={getColumnsFromWindowSize}
getLimitFromWindowSize={getColumnsFromWindowSize}
/>
)}
</I18n>
);
};
export default TutorialsRow;

View File

@@ -0,0 +1,156 @@
// @flow
import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import {
canAccessTutorial,
type TutorialCategory,
type Tutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
import Window from '../../../../Utils/Window';
import { formatDuration } from '../../../../Utils/Duration';
export type LearnCategory =
| TutorialCategory
| null
| 'all-tutorials'
| 'all-courses'
| 'in-app-tutorials';
export const TUTORIAL_CATEGORY_TEXTS = {
'full-game': {
title: <Trans>Make an entire game</Trans>,
description: <Trans>Make complete games step by step</Trans>,
},
'game-mechanic': {
title: <Trans>Specific game mechanics</Trans>,
description: (
<Trans>
Find how to implement the most common game mechanics and more
</Trans>
),
},
'official-beginner': {
title: <Trans>Beginner course</Trans>,
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
},
'official-intermediate': {
title: <Trans>Intermediate course</Trans>,
description: (
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
),
},
'official-advanced': {
title: <Trans>Advanced course</Trans>,
description: <Trans>The icing on the cake</Trans>,
},
'education-curriculum': {
title: <Trans>Education curriculum and resources</Trans>,
description: (
<Trans>
For teachers and educators having the GDevelop Education subscription.
Ready to use resources for teaching.
</Trans>
),
},
course: {
title: <Trans>Loading</Trans>,
description: <Trans>Loading course...</Trans>,
},
recommendations: {
title: <Trans>Recommendations</Trans>,
description: null,
},
};
export const getColumnsFromWindowSize = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
export const getChipColorFromTutorialCategory = (
category: TutorialCategory
): string | null => {
if (category === 'official-beginner') return '#3BF7F4';
if (category === 'official-intermediate') return '#FFBC57';
if (category === 'official-advanced') return '#FF8569';
if (category === 'full-game') return '#FFBC57';
if (category === 'game-mechanic') return '#FFBC57';
return null;
};
export const getChipTextFromTutorialCategory = (
category: TutorialCategory,
i18n: I18nType
) => {
if (category === 'official-beginner') return i18n._(t`Beginner`);
if (category === 'official-intermediate') return i18n._(t`Intermediate`);
if (category === 'official-advanced') return i18n._(t`Advanced`);
if (category === 'full-game') return i18n._(t`Intermediate`);
if (category === 'game-mechanic') return i18n._(t`Intermediate`);
return null;
};
type FormatTutorialToImageTileComponentProps = {|
i18n: I18nType,
limits: ?Limits,
tutorial: Tutorial,
onSelectTutorial: (tutorial: Tutorial) => void,
|};
export const formatTutorialToImageTileComponent = ({
i18n,
tutorial,
limits,
onSelectTutorial,
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
const isLocked = !canAccessTutorial(
tutorial,
limits ? limits.capabilities : null
);
return {
title:
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
description:
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
tutorial.description,
isLocked,
onClick: () => {
if (tutorial.isPrivateTutorial) {
onSelectTutorial(tutorial);
return;
}
sendTutorialOpened(tutorial.id);
Window.openExternalURL(
selectMessageByLocale(i18n, tutorial.linkByLocale)
);
},
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
overlayText: tutorial.duration
? formatDuration(tutorial.duration)
: '\u{1F4D8}',
overlayTextPosition: 'bottomRight',
chipText: getChipTextFromTutorialCategory(tutorial.category, i18n),
chipColor: getChipColorFromTutorialCategory(tutorial.category),
};
};

View File

@@ -3,132 +3,34 @@ import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import { type HomeTab } from '../HomePageMenu';
import {
type TutorialCategory,
type Tutorial,
canAccessTutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
type CourseListingData,
type PrivateGameTemplateListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import MainPage from './MainPage';
import TutorialsCategoryPage from './TutorialsCategoryPage';
import { Trans } from '@lingui/macro';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import PlaceholderError from '../../../../UI/PlaceholderError';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
import Window from '../../../../Utils/Window';
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
import Paper from '../../../../UI/Paper';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { formatDuration } from '../../../../Utils/Duration';
import CourseSection from './CourseSection';
import type {
CourseChapter,
Course,
} from '../../../../Utils/GDevelopServices/Asset';
import type { CourseChapterCompletion, CourseCompletion } from '../UseCourses';
export const TUTORIAL_CATEGORY_TEXTS = {
'full-game': {
title: <Trans>Entire games</Trans>,
description: <Trans>Make complete games step by step</Trans>,
},
'game-mechanic': {
title: <Trans>Specific game mechanics</Trans>,
description: (
<Trans>
Find how to implement the most common game mechanics and more
</Trans>
),
},
'official-beginner': {
title: <Trans>Beginner course</Trans>,
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
},
'official-intermediate': {
title: <Trans>Intermediate course</Trans>,
description: (
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
),
},
'official-advanced': {
title: <Trans>Advanced course</Trans>,
description: <Trans>The icing on the cake</Trans>,
},
'education-curriculum': {
title: <Trans>Education curriculum and resources</Trans>,
description: (
<Trans>
For teachers and educators having the GDevelop Education subscription.
Ready to use resources for teaching.
</Trans>
),
},
course: {
title: <Trans>Loading</Trans>,
description: <Trans>Loading course...</Trans>,
},
recommendations: {
title: <Trans>Recommendations</Trans>,
description: null,
},
};
type FormatTutorialToImageTileComponentProps = {|
i18n: I18nType,
limits: ?Limits,
tutorial: Tutorial,
onSelectTutorial: (tutorial: Tutorial) => void,
|};
export const formatTutorialToImageTileComponent = ({
i18n,
tutorial,
limits,
onSelectTutorial,
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
const isLocked = !canAccessTutorial(
tutorial,
limits ? limits.capabilities : null
);
return {
title:
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
description:
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
tutorial.description,
isLocked,
onClick: () => {
if (tutorial.isPrivateTutorial) {
onSelectTutorial(tutorial);
return;
}
sendTutorialOpened(tutorial.id);
Window.openExternalURL(
selectMessageByLocale(i18n, tutorial.linkByLocale)
);
},
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
overlayText: tutorial.duration
? formatDuration(tutorial.duration)
: '\u{1F4D8}',
overlayTextPosition: 'bottomRight',
};
};
const styles = {
paper: {
flex: 1,
display: 'flex',
},
};
import SectionContainer, { SectionRow } from '../SectionContainer';
import TutorialsPage from './TutorialsPage';
import InAppTutorialsPage from './InAppTutorialsPage';
import CoursesPage from './CoursesPage';
import { type LearnCategory } from './Utils';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
type Props = {|
onTabChange: (tab: HomeTab) => void,
selectInAppTutorial: (tutorialId: string) => void,
selectedCategory: TutorialCategory | null,
onSelectCategory: (TutorialCategory | null) => void,
selectedCategory: LearnCategory,
onSelectCategory: LearnCategory => void,
onOpenTemplateFromTutorial: string => Promise<void>,
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
previewedCourse: ?Course,
@@ -148,7 +50,24 @@ type Props = {|
chapterId: string
) => CourseChapterCompletion | null,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
onBuyCourseWithCredits: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
onBuyCourse: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|};
const LearnSection = ({
@@ -168,13 +87,16 @@ const LearnSection = ({
isCourseTaskCompleted,
getCourseChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
onOpenAskAi,
onOpenNewProjectSetupDialog,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
}: Props) => {
const {
tutorials,
fetchTutorials,
error: tutorialLoadingError,
} = React.useContext(TutorialContext);
const { fetchTutorials } = React.useContext(TutorialContext);
React.useEffect(
() => {
@@ -183,13 +105,22 @@ const LearnSection = ({
[fetchTutorials]
);
if (selectedCategory === 'course' && courseChapters && course) {
if (course) {
if (!courseChapters) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<CourseSection
course={course}
courseChapters={courseChapters}
onBack={() => {
onSelectCategory(null);
onSelectCourse(null);
}}
onOpenTemplateFromCourseChapter={onOpenTemplateFromCourseChapter}
@@ -199,30 +130,19 @@ const LearnSection = ({
getCourseChapterCompletion(course.id, chapterId)
}
getCourseCompletion={() => getCourseCompletion(course.id)}
onBuyCourseChapterWithCredits={onBuyCourseChapterWithCredits}
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
purchasingCourseListingData={purchasingCourseListingData}
setPurchasingCourseListingData={setPurchasingCourseListingData}
onOpenAskAi={onOpenAskAi}
/>
);
}
if (tutorialLoadingError)
return (
<Paper square style={styles.paper} background="dark">
<PlaceholderError onRetry={fetchTutorials}>
<Trans>
Can't load the tutorials. Verify your internet connection or retry
later.
</Trans>
</PlaceholderError>
</Paper>
);
if (!tutorials) return <PlaceholderLoader />;
return !selectedCategory ? (
<MainPage
onTabChange={onTabChange}
onSelectCategory={onSelectCategory}
tutorials={tutorials}
selectInAppTutorial={selectInAppTutorial}
courses={courses}
onSelectCourse={onSelectCourse}
@@ -230,17 +150,36 @@ const LearnSection = ({
previewedCourseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onOpenAskAi={onOpenAskAi}
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
onSelectExampleShortHeader={onSelectExampleShortHeader}
/>
) : selectedCategory === 'all-tutorials' ? (
<TutorialsPage onSelectCategory={onSelectCategory} />
) : selectedCategory === 'in-app-tutorials' ? (
<InAppTutorialsPage
onBack={() => onSelectCategory(null)}
selectInAppTutorial={selectInAppTutorial}
/>
) : selectedCategory === 'all-courses' ? (
<CoursesPage
onBack={() => onSelectCategory(null)}
courses={courses}
onSelectCourse={onSelectCourse}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
/>
) : (
<TutorialsCategoryPage
onBack={() => onSelectCategory(null)}
onBack={() => onSelectCategory('all-tutorials')}
category={selectedCategory}
tutorials={tutorials}
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
onSelectCourse={(courseId: string) => {
onSelectCourse(courseId);
onSelectCategory('course');
}}
onSelectCourse={onSelectCourse}
/>
);
};

View File

@@ -7,10 +7,11 @@ import ArrowLeft from '../../../UI/CustomSvgIcons/ArrowLeft';
import TextButton from '../../../UI/TextButton';
import { Trans } from '@lingui/macro';
import Paper from '../../../UI/Paper';
import { LineStackLayout } from '../../../UI/Layout';
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
import { AnnouncementsFeed } from '../../../AnnouncementsFeed';
import { AnnouncementsFeedContext } from '../../../AnnouncementsFeed/AnnouncementsFeedContext';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import Chip from '../../../UI/Chip';
export const SECTION_DESKTOP_SPACING = 20;
const SECTION_MOBILE_SPACING_TOP = 10;
@@ -53,13 +54,14 @@ const styles = {
minWidth: 0,
flex: 1,
},
chip: { height: 24 },
};
type Props = {|
children: React.Node,
chipText?: React.Node,
title?: React.Node,
titleAdornment?: React.Node,
titleAction?: React.Node,
subtitleText?: React.Node,
customPaperStyle?: Object,
renderSubtitle?: () => React.Node,
@@ -75,9 +77,9 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
(
{
children,
chipText,
title,
titleAdornment,
titleAction,
subtitleText,
customPaperStyle,
renderSubtitle,
@@ -156,14 +158,20 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
alignItems="center"
justifyContent="space-between"
>
<LineStackLayout noMargin alignItems="center">
<Text size="bold-title" noMargin style={styles.title}>
<ColumnStackLayout noMargin expand>
{chipText && (
<Line noMargin>
<Chip label={chipText} style={styles.chip} />
</Line>
)}
<Text size="title" noMargin style={styles.title}>
{title}
</Text>
{titleAction}
</LineStackLayout>
</ColumnStackLayout>
{titleAdornment && (
<Column noMargin>{titleAdornment}</Column>
<Column expand noMargin>
{titleAdornment}
</Column>
)}
</LineStackLayout>
)}

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { type I18n as I18nType } from '@lingui/core';
import {
listCourseChapters,
listCourses,
@@ -9,16 +10,19 @@ import {
type CourseChapter,
type UserCourseProgress,
} from '../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../Utils/GDevelopServices/Shop';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import {
fetchUserCourseProgress,
updateUserCourseProgress as doUpdateUserCourseProgress,
} from '../../../Utils/GDevelopServices/User';
import { useOptimisticState } from '../../../Utils/UseOptimisticState';
import CourseChapterStoreContext from '../../../Course/CourseChapterStoreContext';
import CourseStoreContext from '../../../Course/CourseStoreContext';
import { CreditsPackageStoreContext } from '../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { buyProductWithCredits } from '../../../Utils/GDevelopServices/Shop';
import PreferencesContext from '../../Preferences/PreferencesContext';
import { selectMessageByLocale } from '../../../Utils/i18n/MessageByLocale';
import { sendCourseBuyClicked } from '../../../Utils/Analytics/EventSender';
export type CourseChapterCompletion = {|
completedTasks: number,
@@ -36,8 +40,10 @@ const useCourses = () => {
profile,
limits,
subscription,
coursePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
loginState,
} = React.useContext(AuthenticatedUserContext);
const {
values: { language },
@@ -45,7 +51,11 @@ const useCourses = () => {
const userLanguage2LetterCode = language.split('_')[0].toLowerCase();
const [courses, setCourses] = React.useState<?(Course[])>(null);
const { listedCourseChapters } = React.useContext(CourseChapterStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const [
purchasingCourseListingData,
setPurchasingCourseListingData,
] = React.useState<?CourseListingData>(null);
const { openCreditsPackageDialog, openCreditsUsageDialog } = React.useContext(
CreditsPackageStoreContext
);
@@ -83,10 +93,15 @@ const useCourses = () => {
|}>({});
const userId = profile ? profile.id : null;
const fetchCourses = React.useCallback(async () => {
const fetchedCourses = await listCourses();
setCourses(fetchedCourses);
}, []);
const fetchCourses = React.useCallback(
async () => {
const fetchedCourses = await listCourses(getAuthorizationHeader, {
userId,
});
setCourses(fetchedCourses);
},
[userId, getAuthorizationHeader]
);
const onSelectCourse = React.useCallback(
(courseId: string | null) => {
@@ -138,11 +153,7 @@ const useCourses = () => {
);
}
},
// A subscription change will change the displayed chapters sent by the backend.
// So the user subscription is added as a dependency to make sure the chapters are
// up to date with the user subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps
[getAuthorizationHeader, userId, subscription, userLanguage2LetterCode]
[getAuthorizationHeader, userId, userLanguage2LetterCode]
);
const onCompleteTask = React.useCallback(
@@ -287,14 +298,9 @@ const useCourses = () => {
[userProgressByCourseId, chaptersByCourseId, courses]
);
const onBuyCourseChapterWithCredits = React.useCallback(
async (courseChapter: CourseChapter, password: string) => {
if (
!courseChapter.isLocked ||
!courseChapter.priceInCredits ||
!listedCourseChapters
)
return;
const onBuyCourse = React.useCallback(
async (course: Course, password: string, i18n: I18nType) => {
if (!course.isLocked || !listedCourses) return;
if (!userId || !limits) {
// User not logged in, suggest to log in.
@@ -302,19 +308,68 @@ const useCourses = () => {
return;
}
const currentCreditsAmount = limits.credits.userBalance.amount;
const listedCourseChapter = listedCourseChapters.find(
chapter => chapter.id === courseChapter.productId
const listedCourse = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
if (!listedCourseChapter) {
console.error(
`Couldn't find course chapter with id ${
courseChapter.productId
} in Shop API.`
);
if (!listedCourse) {
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
return;
}
const priceForUsageType = listedCourseChapter.creditPrices.find(
const priceForUsageType = listedCourse.prices.find(
price => price.usageType === 'default'
);
if (!priceForUsageType) {
console.error('Unable to find the price for the default usage type');
return;
}
try {
sendCourseBuyClicked({
courseId: course.id,
courseName: course.titleByLocale.en,
currency: priceForUsageType ? priceForUsageType.currency : undefined,
usageType: 'default',
});
setPurchasingCourseListingData(listedCourse);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[
userId,
limits,
listedCourses,
onOpenLoginDialog,
setPurchasingCourseListingData,
]
);
const onBuyCourseWithCredits = React.useCallback(
async (course: Course, password: string, i18n: I18nType) => {
if (!course.isLocked || !listedCourses) return;
if (!userId || !limits) {
// User not logged in, suggest to log in.
onOpenLoginDialog();
return;
}
sendCourseBuyClicked({
courseId: course.id,
courseName: course.titleByLocale.en,
currency: 'CREDITS',
usageType: 'default',
});
const currentCreditsAmount = limits.credits.userBalance.amount;
const listedCourse = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
if (!listedCourse) {
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
return;
}
const priceForUsageType = listedCourse.creditPrices.find(
price => price.usageType === 'default'
);
if (!priceForUsageType) {
@@ -331,43 +386,48 @@ const useCourses = () => {
return;
}
const translatedCourseTitle = selectMessageByLocale(
i18n,
course.titleByLocale
);
openCreditsUsageDialog({
title: <Trans>Purchase {courseChapter.title}</Trans>,
title: <Trans>Purchase {translatedCourseTitle}</Trans>,
message: (
<Trans>
You are about to use {creditsAmount} credits to purchase the chapter
"{courseChapter.title}". Continue?
You are about to use {creditsAmount} credits to purchase the course
"{translatedCourseTitle}". Continue?
</Trans>
),
onConfirm: async () => {
await buyProductWithCredits(getAuthorizationHeader, {
productId: listedCourseChapter.id,
productId: listedCourse.id,
usageType: 'default',
userId,
password,
});
if (selectedCourseId) await fetchCourseChapters(selectedCourseId);
await Promise.all([fetchCourses(), fetchCourseChapters(course.id)]);
},
successMessage: <Trans>🎉 You can now follow your new chapter!</Trans>,
successMessage: <Trans>🎉 You can now follow your new course!</Trans>,
});
},
[
userId,
limits,
listedCourseChapters,
listedCourses,
openCreditsPackageDialog,
openCreditsUsageDialog,
getAuthorizationHeader,
onOpenLoginDialog,
fetchCourses,
fetchCourseChapters,
selectedCourseId,
]
);
React.useEffect(
() => {
(async () => {
if (courses) {
if (courses && loginState !== 'loggingIn') {
await Promise.all(
courses.map(course => fetchCourseChapters(course.id))
);
@@ -375,15 +435,32 @@ const useCourses = () => {
}
})();
},
// (Re)fetch course chapters when courses are defined and when fetchCourseChapters
// changes (see its dependencies).
[courses, fetchCourseChapters]
// (Re)fetch course chapters when courses are refetched.
[courses, fetchCourseChapters, loginState]
);
const selectedCourse =
selectedCourseId && courses && areChaptersReady
? courses.find(course => course.id === selectedCourseId) || null
: null;
React.useEffect(
() => {
(async () => {
if (subscription || coursePurchases) {
// Just to trigger a re-fetch of the courses when the user subscription changes,
// or when the user purchases a course.
}
if (loginState !== 'loggingIn') {
await fetchCourses();
}
})();
},
[fetchCourses, subscription, coursePurchases, loginState]
);
const selectedCourse = React.useMemo(
() => {
if (!selectedCourseId || !courses || !areChaptersReady) return null;
return courses.find(course => course.id === selectedCourseId) || null;
},
[selectedCourseId, courses, areChaptersReady]
);
return {
courses,
@@ -396,7 +473,10 @@ const useCourses = () => {
isTaskCompleted,
getChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
};
};

View File

@@ -11,12 +11,11 @@ import {
type FileMetadata,
type StorageProvider,
} from '../../../ProjectsStorage';
import GetStartedSection from './GetStartedSection';
import LearnSection from './LearnSection';
import { type LearnCategory } from './LearnSection/Utils';
import PlaySection from './PlaySection';
import CreateSection from './CreateSection';
import StoreSection from './StoreSection';
import { type TutorialCategory } from '../../../Utils/GDevelopServices/Tutorial';
import { TutorialContext } from '../../../Tutorial/TutorialContext';
import { ExampleStoreContext } from '../../../AssetStore/ExampleStore/ExampleStoreContext';
import { HomePageHeader } from './HomePageHeader';
@@ -30,12 +29,6 @@ import TeamProvider from '../../../Profile/Team/TeamProvider';
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
import { type PrivateGameTemplateListingData } from '../../../Utils/GDevelopServices/Shop';
import { PrivateGameTemplateStoreContext } from '../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import PreferencesContext from '../../Preferences/PreferencesContext';
import { incrementGetStartedSectionViewCount } from '../../../Utils/Analytics/LocalStats';
import {
sendUserSurveyHidden,
sendUserSurveyStarted,
} from '../../../Utils/Analytics/EventSender';
import RouterContext, { type RouteArguments } from '../../RouterContext';
import { type GameDetailsTab } from '../../../GameDashboard';
import { canUseClassroomFeature } from '../../../Utils/GDevelopServices/Usage';
@@ -47,6 +40,7 @@ import { type GamesList } from '../../../GameDashboard/UseGamesList';
import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFrame';
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
import useCourses from './UseCourses';
import PreferencesContext from '../../Preferences/PreferencesContext';
import { setEditorHotReloadNeeded } from '../../../EmbeddedGame/EmbeddedGameFrame';
const noop = () => {};
@@ -71,8 +65,6 @@ const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
return 'play';
} else if (routeArguments['initial-dialog'] === 'learn') {
return 'learn';
} else if (routeArguments['initial-dialog'] === 'get-started') {
return 'get-started';
}
return null;
@@ -143,6 +135,7 @@ type Props = {|
selectInAppTutorial: (tutorialId: string) => void,
onOpenPreferences: () => void,
onOpenAbout: () => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
// Project creation
onOpenNewProjectSetupDialog: () => void,
@@ -211,6 +204,7 @@ export const HomePage = React.memo<Props>(
selectInAppTutorial,
onOpenPreferences,
onOpenAbout,
onOpenAskAi,
isActive,
storageProviders,
onSave,
@@ -238,8 +232,6 @@ export const HomePage = React.memo<Props>(
startTimeoutToUnloadIframe,
loadIframeOrRemoveTimeout,
} = gamesPlatformFrameTools;
const userSurveyStartedRef = React.useRef<boolean>(false);
const userSurveyHiddenRef = React.useRef<boolean>(false);
const { fetchTutorials } = React.useContext(TutorialContext);
const { fetchExamplesAndFilters } = React.useContext(ExampleStoreContext);
const {
@@ -273,31 +265,32 @@ export const HomePage = React.memo<Props>(
selectedCourse,
courseChaptersByCourseId,
onSelectCourse,
fetchCourses,
areChaptersReady,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
} = useCourses();
const [
learnCategory,
setLearnCategory,
] = React.useState<TutorialCategory | null>(null);
const [learnCategory, setLearnCategory] = React.useState<LearnCategory>(
null
);
const { isMobile } = useResponsiveWindowSize();
const {
values: { showGetStartedSectionByDefault },
values: { showCreateSectionByDefault },
} = React.useContext(PreferencesContext);
const tabRequestedAtOpening = React.useRef<HomeTab | null>(
getRequestedTab(routeArguments)
);
const initialTab = tabRequestedAtOpening.current
? tabRequestedAtOpening.current
: showGetStartedSectionByDefault
? 'get-started'
: 'create';
: showCreateSectionByDefault
? 'create'
: 'learn';
const [activeTab, setActiveTab] = React.useState<HomeTab>(initialTab);
@@ -354,7 +347,6 @@ export const HomePage = React.memo<Props>(
return;
}
onSelectCourse(courseId);
setLearnCategory('course');
removeRouteArguments(['course-id']);
}
@@ -371,15 +363,6 @@ export const HomePage = React.memo<Props>(
]
);
React.useEffect(
() => {
if (initialTab === 'get-started') {
incrementGetStartedSectionViewCount();
}
},
[initialTab]
);
// Load everything when the user opens the home page, to avoid future loading times.
React.useEffect(
() => {
@@ -390,28 +373,6 @@ export const HomePage = React.memo<Props>(
[fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates]
);
// Only fetch games if the user decides to open the games dashboard tab
// or the build tab to enable the context menu on project list items that
// redirects to the games dashboard.
React.useEffect(
() => {
if (activeTab === 'create' && !games) {
fetchGames();
}
},
[fetchGames, activeTab, games]
);
// Only fetch courses if the user decides to open the Learn section.
React.useEffect(
() => {
if (activeTab === 'learn' && !courses) {
fetchCourses();
}
},
[fetchCourses, activeTab, courses]
);
// Fetch user cloud projects when home page becomes active
React.useEffect(
() => {
@@ -507,28 +468,6 @@ export const HomePage = React.memo<Props>(
forceInGameEditorHotReload,
}));
const onUserSurveyStarted = React.useCallback(() => {
if (userSurveyStartedRef.current) return;
sendUserSurveyStarted();
userSurveyStartedRef.current = true;
}, []);
const onUserSurveyHidden = React.useCallback(() => {
if (userSurveyHiddenRef.current) return;
sendUserSurveyHidden();
userSurveyHiddenRef.current = true;
}, []);
React.useEffect(
() => {
if (!authenticated) {
userSurveyStartedRef.current = false;
userSurveyHiddenRef.current = false;
}
},
// Reset flag that prevents multiple send of the same event on user change.
[authenticated]
);
// As the homepage is never unmounted, we need to ensure the games platform
// iframe is unloaded & loaded from here,
// allowing to handle when the user navigates to another tab.
@@ -598,16 +537,6 @@ export const HomePage = React.memo<Props>(
canSaveProject={canSave}
/>
)}
{activeTab === 'get-started' && (
<GetStartedSection
selectInAppTutorial={selectInAppTutorial}
onUserSurveyStarted={onUserSurveyStarted}
onUserSurveyHidden={onUserSurveyHidden}
onOpenProfile={onOpenProfile}
onCreateProjectFromExample={onCreateProjectFromExample}
askToCloseProject={askToCloseProject}
/>
)}
{activeTab === 'learn' && (
<LearnSection
onTabChange={setActiveTab}
@@ -636,9 +565,18 @@ export const HomePage = React.memo<Props>(
isCourseTaskCompleted={isTaskCompleted}
getCourseChapterCompletion={getChapterCompletion}
getCourseCompletion={getCourseCompletion}
onBuyCourseChapterWithCredits={
onBuyCourseChapterWithCredits
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
purchasingCourseListingData={purchasingCourseListingData}
setPurchasingCourseListingData={
setPurchasingCourseListingData
}
onOpenAskAi={onOpenAskAi}
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
onSelectExampleShortHeader={onSelectExampleShortHeader}
/>
)}
{activeTab === 'play' && (
@@ -732,6 +670,7 @@ export const renderHomePageContainer = (
selectInAppTutorial={props.selectInAppTutorial}
onOpenPreferences={props.onOpenPreferences}
onOpenAbout={props.onOpenAbout}
onOpenAskAi={props.onOpenAskAi}
storageProviders={
(props.extraEditorProps && props.extraEditorProps.storageProviders) || []
}

View File

@@ -22,6 +22,7 @@ type Props = {|
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
mode?: 'chat' | 'agent',
|}) => EditorOpeningOptions,
|};

View File

@@ -46,7 +46,7 @@ export type MainMenuCallbacks = {|
onOpenPreferences: (open?: boolean) => void,
onOpenLanguage: (open?: boolean) => void,
onOpenProfile: (open?: boolean) => void,
onOpenAskAi: (open?: boolean) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
setElectronUpdateStatus: ElectronUpdateStatus => void,
|};

View File

@@ -216,7 +216,7 @@ export type PreferencesValues = {|
resourcesImporationBehavior: ResourceImportationBehavior,
eventsSheetCancelInlineParameter: 'cancel' | 'apply',
showCommunityExtensions: boolean,
showGetStartedSectionByDefault: boolean,
showCreateSectionByDefault: boolean,
showInAppTutorialDeveloperMode: boolean,
showDeprecatedInstructionWarning: boolean,
openDiagnosticReportAutomatically: boolean,
@@ -302,7 +302,7 @@ export type Preferences = {|
setIsAlwaysOnTopInPreview: (enabled: boolean) => void,
setEventsSheetCancelInlineParameter: (value: string) => void,
setShowCommunityExtensions: (enabled: boolean) => void,
setShowGetStartedSectionByDefault: (enabled: boolean) => void,
setShowCreateSectionByDefault: (enabled: boolean) => void,
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
getOpenDiagnosticReportAutomatically: () => boolean,
@@ -381,7 +381,7 @@ export const initialPreferences = {
resourcesImporationBehavior: 'ask',
eventsSheetCancelInlineParameter: 'apply',
showCommunityExtensions: false,
showGetStartedSectionByDefault: true,
showCreateSectionByDefault: false,
showInAppTutorialDeveloperMode: false,
openDiagnosticReportAutomatically: true,
showDeprecatedInstructionWarning: false,
@@ -449,7 +449,7 @@ export const initialPreferences = {
setIsAlwaysOnTopInPreview: () => {},
setEventsSheetCancelInlineParameter: () => {},
setShowCommunityExtensions: () => {},
setShowGetStartedSectionByDefault: (enabled: boolean) => {},
setShowCreateSectionByDefault: (enabled: boolean) => {},
setShowInAppTutorialDeveloperMode: (enabled: boolean) => {},
setShowDeprecatedInstructionWarning: (enabled: boolean) => {},
getOpenDiagnosticReportAutomatically: () => true,

View File

@@ -83,6 +83,7 @@ const PreferencesDialog = ({
setPreviewCrashReportUploadLevel,
setTakeScreenshotOnPreview,
setShowAiAskButtonInTitleBar,
setShowCreateSectionByDefault,
} = React.useContext(PreferencesContext);
const initialUse3DEditor = React.useRef<boolean>(values.use3DEditor);
@@ -392,6 +393,27 @@ const PreferencesDialog = ({
<Trans>Advanced</Trans>
</Text>
<ColumnStackLayout>
<Text size="sub-title">
<Trans>At launch</Trans>
</Text>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setShowCreateSectionByDefault}
checked={values.showCreateSectionByDefault}
label={i18n._(
t`Show the "Create" section by default when opening GDevelop`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setAutoOpenMostRecentProject}
checked={values.autoOpenMostRecentProject}
label={i18n._(
t`Automatically re-open the project edited during last session`
)}
/>
<Text size="sub-title">
<Trans>Previews</Trans>
</Text>
@@ -531,15 +553,6 @@ const PreferencesDialog = ({
t`Display save reminder after significant changes in project`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setAutoOpenMostRecentProject}
checked={values.autoOpenMostRecentProject}
label={i18n._(
t`Automatically re-open the project edited during last session`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip

View File

@@ -149,7 +149,7 @@ export default class PreferencesProvider extends React.Component<Props, State> {
this
),
setShowCommunityExtensions: this._setShowCommunityExtensions.bind(this),
setShowGetStartedSectionByDefault: this._setShowGetStartedSection.bind(
setShowCreateSectionByDefault: this._setShowCreateSectionByDefault.bind(
this
),
setShowInAppTutorialDeveloperMode: this._setShowInAppTutorialDeveloperMode.bind(
@@ -353,12 +353,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
);
}
_setShowGetStartedSection(showGetStartedSectionByDefault: boolean) {
_setShowCreateSectionByDefault(showCreateSectionByDefault: boolean) {
this.setState(
state => ({
values: {
...state.values,
showGetStartedSectionByDefault,
showCreateSectionByDefault,
},
}),
() => this._persistValuesToLocalStorage(this.state)

View File

@@ -40,7 +40,7 @@ import { Trans } from '@lingui/macro';
import { CreditsPackageStoreStateProvider } from '../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/ProductLicenseStoreContext';
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
import { CourseChapterStoreStateProvider } from '../Course/CourseChapterStoreContext';
import { CourseStoreStateProvider } from '../Course/CourseStoreContext';
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
type Props = {|
@@ -108,7 +108,7 @@ const Providers = ({
<ExampleStoreStateProvider>
<PrivateGameTemplateStoreStateProvider>
<CreditsPackageStoreStateProvider>
<CourseChapterStoreStateProvider>
<CourseStoreStateProvider>
<ProductLicenseStoreStateProvider>
<MarketingPlansStoreStateProvider>
<ExtensionStoreStateProvider
@@ -134,7 +134,7 @@ const Providers = ({
</ExtensionStoreStateProvider>
</MarketingPlansStoreStateProvider>
</ProductLicenseStoreStateProvider>
</CourseChapterStoreStateProvider>
</CourseStoreStateProvider>
</CreditsPackageStoreStateProvider>
</PrivateGameTemplateStoreStateProvider>
</ExampleStoreStateProvider>

View File

@@ -12,8 +12,8 @@ export type Route =
| 'build' // Old way of opening the build section
| 'create' // New way of opening the build section
| 'education'
| 'play'
| 'get-started';
| 'learn'
| 'play';
type RouteKey =
| 'initial-dialog'
| 'game-id'

View File

@@ -53,7 +53,7 @@ type TabsTitlebarProps = {|
onEditorTabClosing: () => void
) => React.Node,
hasAskAiOpened: boolean,
onOpenAskAi: () => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|};
const useIsAskAiIconAnimated = (shouldDisplayAskAi: boolean) => {
@@ -227,7 +227,7 @@ export default function TabsTitlebar({
<TextButton
icon={<RobotIcon size={16} rotating={isAskAiIconAnimated} />}
label={'Ask AI'}
onClick={onOpenAskAi}
onClick={() => onOpenAskAi('agent')}
/>
</div>
) : null}

View File

@@ -1,7 +1,18 @@
// @flow
import * as React from 'react';
import RouterContext from './RouterContext';
import RouterContext, { type Route } from './RouterContext';
const homePageRoutes: Route[] = [
'play',
'learn',
'build',
'create',
'games-dashboard',
'asset-store',
'store',
'education',
];
/**
* This hook is used to be able to use route arguments from anywhere to open the homepage.
@@ -22,18 +33,7 @@ const useHomepageWitchForRouting = ({
const initialDialog = routeArguments['initial-dialog'];
if (!initialDialog) return;
if (
[
'games-dashboard',
'asset-store',
'store',
'build',
'education',
'play',
'community',
'get-started',
].includes(initialDialog)
) {
if (homePageRoutes.includes(initialDialog)) {
closeDialogs();
openHomePage();
}

View File

@@ -30,7 +30,7 @@ type Props = {|
privateGameTemplateListingData: PrivateGameTemplateListingData,
newProjectSetup: NewProjectSetup
) => Promise<void>,
openAskAi: () => void,
openAskAi: (mode: 'chat' | 'agent') => void,
storageProviders: Array<StorageProvider>,
|};
@@ -179,7 +179,7 @@ const useNewProjectDialog = ({
const onOpenAskAi = React.useCallback(
() => {
closeNewProjectDialog();
openAskAi();
openAskAi('agent');
},
[closeNewProjectDialog, openAskAi]
);

View File

@@ -616,11 +616,13 @@ const MainFrame = (props: Props) => {
name,
dontFocusTab,
project,
mode,
}: {
kind: EditorKind,
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
mode?: 'chat' | 'agent',
}) => {
const label =
kind === 'resources'
@@ -693,6 +695,8 @@ const MainFrame = (props: Props) => {
const extraEditorProps =
kind === 'start page'
? { storageProviders: props.storageProviders }
: kind === 'ask-ai'
? { mode }
: undefined;
return {
icon,
@@ -1263,11 +1267,11 @@ const MainFrame = (props: Props) => {
});
const openAskAi = React.useCallback(
() => {
(mode: 'chat' | 'agent') => {
setState(state => {
const askAiEditor = getOpenedAskAiEditor(state.editorTabs);
if (askAiEditor) {
askAiEditor.startNewChat();
askAiEditor.startNewChat(mode);
}
// Open or focus the AI editor.
@@ -1275,7 +1279,7 @@ const MainFrame = (props: Props) => {
...state,
editorTabs: openEditorTab(
state.editorTabs,
getEditorOpeningOptions({ kind: 'ask-ai', name: '' })
getEditorOpeningOptions({ kind: 'ask-ai', name: '', mode })
),
};
});
@@ -4291,6 +4295,7 @@ const MainFrame = (props: Props) => {
hideTabsTitleBarAndEditorToolbar: setTabsTitleBarAndEditorToolbarHidden,
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenAskAi: openAskAi,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) => {
openLayout(sceneName, {

View File

@@ -15,6 +15,16 @@ import * as THREE from 'three';
const gd: libGDevelop = global.gd;
const getEventBasedObject = (
project: gdProject,
customObjectConfiguration: gdCustomObjectConfiguration
): gdEventsBasedObject | null => {
const type = customObjectConfiguration.getType();
return project.hasEventsBasedObject(type)
? project.getEventsBasedObject(type)
: null;
};
const getVariant = (
eventBasedObject: gdEventsBasedObject,
customObjectConfiguration: gdCustomObjectConfiguration
@@ -93,7 +103,6 @@ const getPropertyMappingRules = (
*/
export default class RenderedCustomObjectInstance extends Rendered3DInstance
implements LayoutedParent<RenderedInstance | Rendered3DInstance> {
eventBasedObject: gdEventsBasedObject | null;
_isRenderedIn3D = false;
/** Functor used to render an instance */
@@ -137,14 +146,10 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
const customObjectConfiguration = gd.asCustomObjectConfiguration(
associatedObjectConfiguration
);
this.eventBasedObject = project.hasEventsBasedObject(
customObjectConfiguration.getType()
)
? project.getEventsBasedObject(customObjectConfiguration.getType())
: null;
const { eventBasedObject } = this;
const eventBasedObject = getEventBasedObject(
project,
customObjectConfiguration
);
if (!eventBasedObject) {
return;
}
@@ -218,13 +223,16 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
_getChildObjectConfiguration = (
childObjectName: string
): gdObjectConfiguration | null => {
const eventBasedObject = this.eventBasedObject;
if (!eventBasedObject) {
return null;
}
const customObjectConfiguration = gd.asCustomObjectConfiguration(
this._associatedObjectConfiguration
);
const eventBasedObject = getEventBasedObject(
this._project,
customObjectConfiguration
);
if (!eventBasedObject) {
return null;
}
const variant = getVariant(eventBasedObject, customObjectConfiguration);
if (!variant) {
return null;
@@ -363,12 +371,10 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
const customObjectConfiguration = gd.asCustomObjectConfiguration(
objectConfiguration
);
const eventBasedObject = project.hasEventsBasedObject(
customObjectConfiguration.getType()
)
? project.getEventsBasedObject(customObjectConfiguration.getType())
: null;
const eventBasedObject = getEventBasedObject(
project,
customObjectConfiguration
);
if (!eventBasedObject) {
return 'res/unknown32.png';
}
@@ -432,20 +438,32 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
}
getVariant(): gdEventsBasedObjectVariant | null {
const { eventBasedObject } = this;
if (!eventBasedObject) {
return null;
}
const customObjectConfiguration = gd.asCustomObjectConfiguration(
this._associatedObjectConfiguration
);
const eventBasedObject = getEventBasedObject(
this._project,
customObjectConfiguration
);
if (!eventBasedObject) {
return null;
}
return getVariant(eventBasedObject, customObjectConfiguration);
}
update() {
const { eventBasedObject } = this;
const variant = this.getVariant();
if (!eventBasedObject || !variant) {
const customObjectConfiguration = gd.asCustomObjectConfiguration(
this._associatedObjectConfiguration
);
const eventBasedObject = getEventBasedObject(
this._project,
customObjectConfiguration
);
if (!eventBasedObject) {
return;
}
const variant = getVariant(eventBasedObject, customObjectConfiguration);
if (!variant) {
return;
}

View File

@@ -42,6 +42,7 @@ export type AuthenticatedUser = {|
receivedGameTemplates: ?Array<PrivateGameTemplate>,
gameTemplatePurchases: ?Array<Purchase>,
assetPackPurchases: ?Array<Purchase>,
coursePurchases: ?Array<Purchase>,
recommendations: ?Array<Recommendation>,
notifications: ?Array<Notification>,
userEarningsBalance: ?UserEarningsBalance,
@@ -74,6 +75,7 @@ export type AuthenticatedUser = {|
onRefreshLimits: () => Promise<void>,
onRefreshGameTemplatePurchases: () => Promise<void>,
onRefreshAssetPackPurchases: () => Promise<void>,
onRefreshCoursePurchases: () => Promise<void>,
onRefreshEarningsBalance: () => Promise<void>,
onRefreshNotifications: () => Promise<void>,
onPurchaseSuccessful: () => Promise<void>,
@@ -105,6 +107,7 @@ export const initialAuthenticatedUser = {
cloudProjectsFetchingErrorLabel: null,
gameTemplatePurchases: null,
assetPackPurchases: null,
coursePurchases: null,
recommendations: null,
subscription: null,
usages: null,
@@ -131,6 +134,7 @@ export const initialAuthenticatedUser = {
onRefreshLimits: async () => {},
onRefreshGameTemplatePurchases: async () => {},
onRefreshAssetPackPurchases: async () => {},
onRefreshCoursePurchases: async () => {},
onRefreshEarningsBalance: async () => {},
onRefreshNotifications: async () => {},
onPurchaseSuccessful: async () => {},

View File

@@ -219,6 +219,7 @@ export default class AuthenticatedUserProvider extends React.Component<
onRefreshLimits: this._fetchUserLimits,
onRefreshGameTemplatePurchases: this._fetchUserGameTemplatePurchases,
onRefreshAssetPackPurchases: this._fetchUserAssetPackPurchases,
onRefreshCoursePurchases: this._fetchUserCoursePurchases,
onRefreshEarningsBalance: this._fetchEarningsBalance,
onRefreshNotifications: this._fetchUserNotifications,
onPurchaseSuccessful: this._fetchUserProducts,
@@ -560,6 +561,22 @@ export default class AuthenticatedUserProvider extends React.Component<
console.error('Error while loading asset pack purchases:', error);
}
);
listUserPurchases(authentication.getAuthorizationHeader, {
userId: firebaseUser.uid,
productType: 'course',
role: 'receiver',
}).then(
coursePurchases =>
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
coursePurchases,
},
})),
error => {
console.error('Error while loading course purchases:', error);
}
);
this._fetchUserBadges();
this._fetchAchievements();
this._fetchUserNotifications();
@@ -849,6 +866,32 @@ export default class AuthenticatedUserProvider extends React.Component<
}
};
_fetchUserCoursePurchases = async () => {
const { authentication } = this.props;
const firebaseUser = this.state.authenticatedUser.firebaseUser;
if (!firebaseUser) return;
try {
const coursePurchases = await listUserPurchases(
authentication.getAuthorizationHeader,
{
userId: firebaseUser.uid,
productType: 'course',
role: 'receiver',
}
);
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
coursePurchases,
},
}));
} catch (error) {
console.error('Error while loading course purchases:', error);
}
};
_fetchUserProducts = async () => {
await Promise.all([
this._fetchUserAssetPacks(),

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