Compare commits

...

20 Commits

Author SHA1 Message Date
Florian Rival
4dbabab052 Bump newIDE version 2022-09-30 15:43:18 +02:00
D8H
827c5d6442 Add an extension category filter (#4341) 2022-09-30 15:14:23 +02:00
Florian Rival
46be0e0ffc Change previews so that they use the development environment for GDevelop APIs if running GDevelop development version (#4343)
Only show in developer changelog
2022-09-30 14:55:50 +02:00
github-actions[bot]
72e3cf5b99 Update translations (#4313) 2022-09-30 10:12:34 +02:00
D8H
54f32a2542 Add new categories for extensions (#4331) 2022-09-29 18:24:06 +02:00
D8H
b826f66455 Make the custom object renderer works better with 9-patch and text child-objects (#4335)
* Don't show in changelogs.
2022-09-29 17:16:41 +02:00
D8H
6fc03cccc6 Add help buttons in the expression editor and the extension details dialog (#4337) 2022-09-29 15:44:16 +02:00
AlexandreS
ed7313a330 Add invert condition shortcut (J key by default - configurable in your preferences) (#4334) 2022-09-29 11:18:33 +02:00
D8H
7390f7cd6a Ensure required behavior properties can't be hidden (#4336) 2022-09-29 09:54:01 +02:00
D8H
4619ae824b Allow event-based objects to define a default name for created objects (#4329)
* Don't show in changelogs
2022-09-28 17:43:51 +02:00
D8H
0f69ee435f Add tests on behavior properties initialization and unserialization (#4314)
* Don't show in changelogs
2022-09-28 17:43:18 +02:00
AlexandreS
f46241d5a2 Fix bug that prevented converting a variable to JSON when one previously tried to access an out-of-index child in a variable array (#4333) 2022-09-28 17:39:53 +02:00
D8H
4c8ec48004 Add subsections for extensions categories in the Wiki (#4332)
* Add a warning message on pages for extensions from the community list.
2022-09-28 15:34:03 +02:00
AlexandreS
3ac121be4c Add asset packs that can be purchased in the Asset store home (#4328)
Do not show in changelog
2022-09-28 15:29:45 +02:00
D8H
3aa636861c Custom objects take the icon of one of their sprite child (#4316)
* Don't show in changelogs.
2022-09-27 16:19:12 +02:00
D8H
6d4b422be6 Fix extension description links on the Wiki (#4325) 2022-09-27 16:18:18 +02:00
Clément Pasteau
b8ee27f62c Improve player authentication
* Improve player authentication by indicating when the game is not registered
* Show a link to open the window if blocked

Do not show in changelog
2022-09-27 15:19:06 +02:00
AlexandreS
6996ff452d Fix various memory leaks when using the app (#4323) 2022-09-23 18:34:34 +02:00
AlexandreS
da7934c6ac Add context menu items to manipulate the view on the scene editor (#4307)
- Return to initial position (view matches the game resolution)
- Fit zoom to selected instances
- Fit zoom to the whole scene
- Select all instances of an object on the scene (in the context menu of an object)
2022-09-23 13:27:40 +02:00
Clément Pasteau
90bebcb404 Create GDevelop Authentication extension
* This is an experimental extension!
* It allows you to provide a login/register form to your players, with 1 action
* It connects the player automatically when they launch your game again
* It also provides a new action to submit a leaderboard entry without having to enter a username
* This is the beginning of Player Authentication and more features will come allowing creators to interact with their players
2022-09-23 10:44:58 +02:00
167 changed files with 4786 additions and 1041 deletions

View File

@@ -23,12 +23,16 @@ EventsBasedObject::EventsBasedObject(const gd::EventsBasedObject &_eventBasedObj
}
void EventsBasedObject::SerializeTo(SerializerElement& element) const {
element.SetAttribute("defaultName", defaultName);
AbstractEventsBasedEntity::SerializeTo(element);
SerializeObjectsTo(element.AddChild("objects"));
}
void EventsBasedObject::UnserializeFrom(gd::Project& project,
const SerializerElement& element) {
defaultName = element.GetStringAttribute("defaultName");
AbstractEventsBasedEntity::UnserializeFrom(project, element);
UnserializeObjectsFrom(project, element.GetChild("objects"));
}

View File

@@ -38,6 +38,19 @@ class GD_CORE_API EventsBasedObject: public AbstractEventsBasedEntity, public Ob
*/
EventsBasedObject* Clone() const { return new EventsBasedObject(*this); };
/**
* \brief Get the default name for created objects.
*/
const gd::String& GetDefaultName() const { return defaultName; };
/**
* \brief Set the default name for created objects.
*/
EventsBasedObject& SetDefaultName(const gd::String& defaultName_) {
defaultName = defaultName_;
return *this;
}
EventsBasedObject& SetDescription(const gd::String& description_) override {
AbstractEventsBasedEntity::SetDescription(description_);
return *this;
@@ -65,6 +78,7 @@ class GD_CORE_API EventsBasedObject: public AbstractEventsBasedEntity, public Ob
const SerializerElement& element) override;
private:
gd::String defaultName;
};
} // namespace gd

View File

@@ -498,7 +498,7 @@ module.exports = {
RenderedBBTextInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'JsPlatform/Extensions/bbcode24.png';
};

View File

@@ -627,7 +627,7 @@ module.exports = {
RenderedBitmapTextInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'JsPlatform/Extensions/bitmapfont24.png';
};

View File

@@ -539,7 +539,7 @@ module.exports = {
RenderedDummyObjectInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'CppPlatform/Extensions/texticon24.png';
};

View File

@@ -20,7 +20,7 @@ export type ObjectsRenderingService = {
RenderedInstance: any,
registerInstanceRenderer: (objectType: string, renderer: any) => void,
requireModule: (dirname: string, moduleName: string) => any,
getThumbnail: (project: gdProject, object: gdObject) => string,
getThumbnail: (project: gdProject, objectConfiguration: gdObjectConfiguration) => string,
rgbOrHexToHexNumber: (value: string) => number,
};
export type ObjectsEditorService = {

View File

@@ -34,7 +34,7 @@ module.exports = {
'Open source (MIT License)'
)
.setExtensionHelpPath('/all-features/leaderboards')
.setCategory('Leaderboards')
.setCategory('Players')
.addInstructionOrExpressionGroupMetadata(_('Leaderboards (experimental)'))
.setIcon('JsPlatform/Extensions/leaderboard.svg');
@@ -65,6 +65,32 @@ module.exports = {
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
extension
.addAction(
'SaveConnectedPlayerScore',
_('Save connected player score'),
_("Save the connected player's score to the given leaderboard."),
_(
'Send to leaderboard _PARAM1_ the score _PARAM2_ for the connected player'
),
_('Save score'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('leaderboardId', _('Leaderboard'), '', false)
.addParameter(
'expression',
_('Score to register for the player'),
'',
false
)
.setHelpPath('/all-features/leaderboards')
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/sha256.js')
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.saveConnectedPlayerScore');
extension
.addCondition(
'HasLastSaveErrored',

View File

@@ -16,8 +16,10 @@ namespace gdjs {
lastScoreSavingSucceededAt: number | null;
currentlySavingScore: number | null;
currentlySavingPlayerName: string | null;
currentlySavingPlayerId: string | null;
lastSavedScore: number | null;
lastSavedPlayerName: string | null;
lastSavedPlayerId: string | null;
lastSaveError: string | null;
isScoreSaving: boolean;
hasScoreBeenSaved: boolean;
@@ -28,25 +30,45 @@ namespace gdjs {
this.lastScoreSavingSucceededAt = null;
this.currentlySavingScore = null;
this.currentlySavingPlayerName = null;
this.currentlySavingPlayerId = null;
this.lastSavedScore = null;
this.lastSavedPlayerName = null;
this.lastSavedPlayerId = null;
this.lastSaveError = null;
this.isScoreSaving = false;
this.hasScoreBeenSaved = false;
this.hasScoreSavingErrored = false;
}
isSameAsLastScore(playerName: string, score: number): boolean {
isSameAsLastScore({
playerName,
playerId,
score,
}: {
playerName?: string;
playerId?: string;
score: number;
}): boolean {
return (
this.lastSavedPlayerName === playerName &&
((!!playerName && this.lastSavedPlayerName === playerName) ||
(!!playerId && this.lastSavedPlayerId === playerId)) &&
this.lastSavedScore === score
);
}
isAlreadySavingThisScore(playerName: string, score: number): boolean {
isAlreadySavingThisScore({
playerName,
playerId,
score,
}: {
playerName?: string;
playerId?: string;
score: number;
}): boolean {
return (
((!!playerName && this.currentlySavingPlayerName === playerName) ||
(!!playerId && this.currentlySavingPlayerId === playerId)) &&
this.isScoreSaving &&
this.currentlySavingPlayerName === playerName &&
this.currentlySavingScore === score
);
}
@@ -58,19 +80,29 @@ namespace gdjs {
);
}
startSaving(playerName: string, score: number): void {
startSaving({
playerName,
playerId,
score,
}: {
playerName?: string;
playerId?: string;
score: number;
}): void {
this.lastScoreSavingStartedAt = Date.now();
this.isScoreSaving = true;
this.hasScoreBeenSaved = false;
this.hasScoreSavingErrored = false;
this.currentlySavingScore = score;
this.currentlySavingPlayerName = playerName;
if (playerName) this.currentlySavingPlayerName = playerName;
if (playerId) this.currentlySavingPlayerId = playerId;
}
closeSaving(): void {
this.lastScoreSavingSucceededAt = Date.now();
this.lastSavedScore = this.currentlySavingScore;
this.lastSavedPlayerName = this.currentlySavingPlayerName;
this.lastSavedPlayerId = this.currentlySavingPlayerId;
this.isScoreSaving = false;
this.hasScoreBeenSaved = true;
}
@@ -155,50 +187,31 @@ namespace gdjs {
return lastScoreSavingState;
};
export const savePlayerScore = function (
runtimeScene: gdjs.RuntimeScene,
leaderboardId: string,
score: float,
playerName: string
) {
let scoreSavingState: ScoreSavingState;
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
if (scoreSavingState.isAlreadySavingThisScore(playerName, score)) {
logger.warn(
'There is already a request to save with this player name and this score. Ignoring this one.'
);
return;
}
if (scoreSavingState.isSameAsLastScore(playerName, score)) {
logger.warn(
'The player and score to be sent are the same as previous one. Ignoring this one.'
);
const errorCode = 'SAME_AS_PREVIOUS';
scoreSavingState.setError(errorCode);
return;
}
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
const errorCode = 'TOO_FAST';
scoreSavingState.setError(errorCode);
return;
}
} else {
scoreSavingState = new ScoreSavingState();
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
}
scoreSavingState.startSaving(playerName, score);
const baseUrl = 'https://api.gdevelop-app.com/play';
const saveScore = function ({
leaderboardId,
playerName,
playerId,
playerToken,
score,
scoreSavingState,
runtimeScene,
}: {
leaderboardId: string;
playerName?: string;
playerId?: string;
playerToken?: string;
score: number;
scoreSavingState: ScoreSavingState;
runtimeScene: gdjs.RuntimeScene;
}) {
const rootApi = runtimeScene
.getGame()
.isUsingGDevelopDevelopmentEnvironment()
? 'https://api-dev.gdevelop.io'
: 'https://api.gdevelop.io';
const baseUrl = `${rootApi}/play`;
const game = runtimeScene.getGame();
const payload = JSON.stringify({
playerName: formatPlayerName(playerName),
const payloadObject = {
score: score,
sessionId: game.getSessionId(),
clientPlayerId: game.getPlayerId(),
@@ -206,18 +219,25 @@ namespace gdjs {
typeof window !== 'undefined' && (window as any).location
? (window as any).location.href
: '',
});
fetch(
`${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`,
{
body: payload,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Digest: computeDigest(payload),
},
}
).then(
};
if (playerName)
payloadObject['playerName'] = formatPlayerName(playerName);
const payload = JSON.stringify(payloadObject);
const headers = {
'Content-Type': 'application/json',
Digest: computeDigest(payload),
};
if (playerToken)
headers['Authorization'] = `player-game-token ${playerToken}`;
let leaderboardEntryCreationUrl = `${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`;
if (playerId) {
leaderboardEntryCreationUrl += `?playerId=${playerId}`;
}
fetch(leaderboardEntryCreationUrl, {
body: payload,
method: 'POST',
headers: headers,
}).then(
(response) => {
if (!response.ok) {
const errorCode = response.status.toString();
@@ -250,6 +270,114 @@ namespace gdjs {
);
};
export const savePlayerScore = function (
runtimeScene: gdjs.RuntimeScene,
leaderboardId: string,
score: float,
playerName: string
) {
let scoreSavingState: ScoreSavingState;
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
if (
scoreSavingState.isAlreadySavingThisScore({ playerName, score })
) {
logger.warn(
'There is already a request to save with this player name and this score. Ignoring this one.'
);
return;
}
if (scoreSavingState.isSameAsLastScore({ playerName, score })) {
logger.warn(
'The player and score to be sent are the same as previous one. Ignoring this one.'
);
const errorCode = 'SAME_AS_PREVIOUS';
scoreSavingState.setError(errorCode);
return;
}
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
const errorCode = 'TOO_FAST';
scoreSavingState.setError(errorCode);
return;
}
} else {
scoreSavingState = new ScoreSavingState();
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
}
scoreSavingState.startSaving({ playerName, score });
saveScore({
leaderboardId,
playerName,
score,
scoreSavingState,
runtimeScene,
});
};
export const saveConnectedPlayerScore = function (
runtimeScene: gdjs.RuntimeScene,
leaderboardId: string,
score: float
) {
let scoreSavingState: ScoreSavingState;
const playerId = gdjs.playerAuthentication.getUserId();
const playerToken = gdjs.playerAuthentication.getUserToken();
if (!playerId || !playerToken) {
logger.warn(
'Cannot save a score for a connected player if the player is not connected.'
);
return;
}
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
if (scoreSavingState.isAlreadySavingThisScore({ playerId, score })) {
logger.warn(
'There is already a request to save with this player ID and this score. Ignoring this one.'
);
return;
}
if (scoreSavingState.isSameAsLastScore({ playerId, score })) {
logger.warn(
'The player and score to be sent are the same as previous one. Ignoring this one.'
);
const errorCode = 'SAME_AS_PREVIOUS';
scoreSavingState.setError(errorCode);
return;
}
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
const errorCode = 'TOO_FAST';
scoreSavingState.setError(errorCode);
return;
}
} else {
scoreSavingState = new ScoreSavingState();
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
}
scoreSavingState.startSaving({ playerId, score });
saveScore({
leaderboardId,
playerId,
playerToken,
score,
scoreSavingState,
runtimeScene,
});
};
export const isSaving = function (leaderboardId?: string): boolean {
if (leaderboardId) {
return _scoreSavingStateByLeaderboard[leaderboardId]
@@ -512,7 +640,12 @@ namespace gdjs {
}
const gameId = gdjs.projectData.properties.projectUuid;
const targetUrl = `https://liluo.io/games/${gameId}/leaderboard/${leaderboardId}?inGameEmbedded=true`;
const isDev = runtimeScene
.getGame()
.isUsingGDevelopDevelopmentEnvironment();
const targetUrl = `https://liluo.io/games/${gameId}/leaderboard/${leaderboardId}?inGameEmbedded=true${
isDev ? '&dev=true' : ''
}`;
checkLeaderboardAvailability(targetUrl).then(
(isAvailable) => {
if (leaderboardId !== _requestedLeaderboardId) {

View File

@@ -370,7 +370,7 @@ module.exports = {
RenderedLightObjectInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'CppPlatform/Extensions/lightIcon32.png';
};

View File

@@ -3816,6 +3816,23 @@ module.exports = {
gd /*: libGDevelop */,
extension /*: gdPlatformExtension*/
) {
return [];
const dummyBehavior = extension
.getBehaviorMetadata('Physics2::Physics2Behavior')
.get();
const sharedData = extension
.getBehaviorMetadata('Physics2::Physics2Behavior')
.getSharedDataInstance();
return [
gd.ProjectHelper.sanityCheckBehaviorProperty(
dummyBehavior,
'density',
'123'
),
gd.ProjectHelper.sanityCheckBehaviorsSharedDataProperty(
sharedData,
'gravityY',
'456'
),
];
},
};

View File

@@ -0,0 +1,197 @@
// @flow
/**
* This is a declaration of an extension for GDevelop 5.
*
* Changes in this file are watched and automatically imported if the editor
* is running. You can also manually run `node import-GDJS-Runtime.js` (in newIDE/app/scripts).
*
* The file must be named "JsExtension.js", otherwise GDevelop won't load it.
* ⚠️ If you make a change and the extension is not loaded, open the developer console
* and search for any errors.
*
* More information on https://github.com/4ian/GDevelop/blob/master/newIDE/README-extensions.md
*/
/*::
// Import types to allow Flow to do static type checking on this file.
// Extensions declaration are typed using Flow (like the editor), but the files
// for the game engine are checked with TypeScript annotations.
import { type ObjectsRenderingService, type ObjectsEditorService } from '../JsExtensionTypes.flow.js'
*/
module.exports = {
createExtension: function (
_ /*: (string) => string */,
gd /*: libGDevelop */
) {
const extension = new gd.PlatformExtension();
extension
.setExtensionInformation(
'PlayerAuthentication',
_('Player Authentication (experimental)'),
_('Allow your game to authenticate players.'),
'Florian Rival',
'Open source (MIT License)'
)
.setExtensionHelpPath('/all-features/player-authentication')
.setCategory('Players');
extension
.addInstructionOrExpressionGroupMetadata(
_('Player Authentication (experimental)')
)
.setIcon('JsPlatform/Extensions/authentication.svg');
extension
.addDependency()
.setName('InAppBrowser Cordova plugin')
.setDependencyType('cordova')
.setExportName('cordova-plugin-inappbrowser');
extension
.addAction(
'DisplayAuthenticationBanner',
_('Display authentication banner'),
_(
'Display an authentication banner at the top of the game screen, for the player to log in.'
),
_('Display an authentication banner'),
'',
'JsPlatform/Extensions/authentication.svg',
'JsPlatform/Extensions/authentication.svg'
)
.addCodeOnlyParameter('currentScene', '')
.setHelpPath('/all-features/player-authentication')
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.displayAuthenticationBanner');
extension
.addAction(
'OpenAuthenticationWindow',
_('Open authentication window'),
_('Open an authentication window for the player to log in.'),
_('Open an authentication window'),
'',
'JsPlatform/Extensions/authentication.svg',
'JsPlatform/Extensions/authentication.svg'
)
.addCodeOnlyParameter('currentScene', '')
.setHelpPath('/all-features/player-authentication')
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.openAuthenticationWindow');
extension
.addCondition(
'IsAuthenticationWindowOpen',
_('Authentication window is open'),
_('Check if the authentication window is open.'),
_('Authentication window is open'),
'',
'JsPlatform/Extensions/authentication.svg',
'JsPlatform/Extensions/authentication.svg'
)
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.isAuthenticationWindowOpen');
extension
.addAction(
'LogOut',
_('Log out the player'),
_('Log out the player.'),
_('Log out the player'),
'',
'JsPlatform/Extensions/authentication.svg',
'JsPlatform/Extensions/authentication.svg'
)
.addCodeOnlyParameter('currentScene', '')
.setHelpPath('/all-features/player-authentication')
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.logout');
extension
.addStrExpression(
'Username',
_('Username'),
_('Get the username of the authenticated player.'),
'',
'JsPlatform/Extensions/authentication.svg'
)
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.getUsername');
extension
.addCondition(
'IsPlayerAuthenticated',
_('Player is authenticated'),
_('Check if the player is authenticated.'),
_('Player is authenticated'),
'',
'JsPlatform/Extensions/authentication.svg',
'JsPlatform/Extensions/authentication.svg'
)
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.isAuthenticated');
extension
.addCondition(
'HasPlayerLoggedIn',
_('Player has logged in'),
_('Check if the player has just logged in.'),
_('Player has logged in'),
'',
'JsPlatform/Extensions/authentication.svg',
'JsPlatform/Extensions/authentication.svg'
)
.getCodeExtraInformation()
.setIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
)
.addIncludeFile(
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
)
.setFunctionName('gdjs.playerAuthentication.hasLoggedIn');
return extension;
},
runExtensionSanityTests: function (
gd /*: libGDevelop */,
extension /*: gdPlatformExtension*/
) {
return [];
},
};

View File

@@ -0,0 +1,448 @@
namespace gdjs {
const logger = new gdjs.Logger('Player Authentication');
export namespace playerAuthenticationComponents {
const getPlayerLoginMessages = ({
platform,
isGameRegistered,
}: {
platform: 'cordova' | 'electron' | 'web';
isGameRegistered: boolean;
}) =>
isGameRegistered
? {
title: 'Logging in...',
text1:
platform === 'cordova'
? "One moment, we're opening a window for you to log in."
: "One moment, we're opening a new page with your web browser for you to log in.",
text2:
'If the window did not open, please check your pop-up blocker and click the button below to try again.',
}
: {
title: 'Your game is not registered!',
text1:
'In order to use player authentication, this game must be registered with GDevelop Services first.',
text2: 'Head to your Game Dashboard, then try again.',
};
/**
* Creates a DOM element that will contain the loader or a message if the game is not registered.
*/
export const computeAuthenticationContainer = function (
onCloseAuthenticationContainer: () => void
): {
rootContainer: HTMLDivElement;
loaderContainer: HTMLDivElement;
} {
const rootContainer = document.createElement('div');
rootContainer.id = 'authentication-root-container';
rootContainer.style.position = 'relative';
rootContainer.style.backgroundColor = 'rgba(14, 6, 45, 0.5)';
rootContainer.style.opacity = '1';
rootContainer.style.width = '100%';
rootContainer.style.height = '100%';
rootContainer.style.zIndex = '2';
rootContainer.style.pointerEvents = 'all';
const subContainer = document.createElement('div');
subContainer.id = 'authentication-sub-container';
subContainer.style.backgroundColor = '#FFFFFF';
subContainer.style.position = 'absolute';
subContainer.style.top = '16px';
subContainer.style.bottom = '16px';
subContainer.style.left = '16px';
subContainer.style.right = '16px';
subContainer.style.borderRadius = '8px';
subContainer.style.boxShadow = '0px 4px 4px rgba(0, 0, 0, 0.25)';
subContainer.style.padding = '16px';
const _closeContainer: HTMLDivElement = document.createElement('div');
_closeContainer.style.cursor = 'pointer';
_closeContainer.style.display = 'flex';
_closeContainer.style.justifyContent = 'right';
_closeContainer.style.alignItems = 'center';
_closeContainer.style.zIndex = '3';
addTouchAndClickEventListeners(
_closeContainer,
onCloseAuthenticationContainer
);
const _close = document.createElement('img');
_close.setAttribute('width', '15px');
_close.setAttribute(
'src',
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI4IiB2aWV3Qm94PSIwIDAgOCA4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTcuODUzNTUgMC4xNDY0NDdDOC4wNDg4MiAwLjM0MTcwOSA4LjA0ODgyIDAuNjU4MjkxIDcuODUzNTUgMC44NTM1NTNMMC44NTM1NTMgNy44NTM1NUMwLjY1ODI5MSA4LjA0ODgyIDAuMzQxNzA5IDguMDQ4ODIgMC4xNDY0NDcgNy44NTM1NUMtMC4wNDg4MTU1IDcuNjU4MjkgLTAuMDQ4ODE1NSA3LjM0MTcxIDAuMTQ2NDQ3IDcuMTQ2NDVMNy4xNDY0NSAwLjE0NjQ0N0M3LjM0MTcxIC0wLjA0ODgxNTUgNy42NTgyOSAtMC4wNDg4MTU1IDcuODUzNTUgMC4xNDY0NDdaIiBmaWxsPSIjMUQxRDI2Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMC4xNDY0NDcgMC4xNDY0NDdDMC4zNDE3MDkgLTAuMDQ4ODE1NSAwLjY1ODI5MSAtMC4wNDg4MTU1IDAuODUzNTUzIDAuMTQ2NDQ3TDcuODUzNTUgNy4xNDY0NUM4LjA0ODgyIDcuMzQxNzEgOC4wNDg4MiA3LjY1ODI5IDcuODUzNTUgNy44NTM1NUM3LjY1ODI5IDguMDQ4ODIgNy4zNDE3MSA4LjA0ODgyIDcuMTQ2NDUgNy44NTM1NUwwLjE0NjQ0NyAwLjg1MzU1M0MtMC4wNDg4MTU1IDAuNjU4MjkxIC0wLjA0ODgxNTUgMC4zNDE3MDkgMC4xNDY0NDcgMC4xNDY0NDdaIiBmaWxsPSIjMUQxRDI2Ii8+Cjwvc3ZnPgo='
);
_closeContainer.appendChild(_close);
const loaderContainer: HTMLDivElement = document.createElement('div');
loaderContainer.id = 'authentication-container-loader';
loaderContainer.style.display = 'flex';
loaderContainer.style.flexDirection = 'column';
loaderContainer.style.height = '100%';
loaderContainer.style.width = '100%';
loaderContainer.style.justifyContent = 'center';
loaderContainer.style.alignItems = 'center';
const _loader = document.createElement('img');
_loader.setAttribute('width', '28px');
_loader.setAttribute(
'src',
'data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iYW5pbWF0ZS1zcGluIC1tbC0xIG1yLTMgaC01IHctNSB0ZXh0LXdoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgb3BhY2l0eT0nMC4yNScgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSI0Ij48L2NpcmNsZT4KPHBhdGggb3BhY2l0eT0nMC43NScgZmlsbD0iY3VycmVudENvbG9yIiBkPSJNNCAxMmE4IDggMCAwMTgtOFYwQzUuMzczIDAgMCA1LjM3MyAwIDEyaDR6bTIgNS4yOTFBNy45NjIgNy45NjIgMCAwMTQgMTJIMGMwIDMuMDQyIDEuMTM1IDUuODI0IDMgNy45MzhsMy0yLjY0N3oiPjwvcGF0aD4KPC9zdmc+'
);
_loader.style.marginTop = '50px';
try {
_loader.animate(
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(359deg)' }],
{
duration: 3000,
iterations: Infinity,
}
);
} catch {
logger.warn('Animation not supported, loader will be fixed.');
}
loaderContainer.appendChild(_loader);
subContainer.appendChild(_closeContainer);
subContainer.appendChild(loaderContainer);
rootContainer.appendChild(subContainer);
return { rootContainer, loaderContainer };
};
/**
* Helper to add the texts to the authentication container
* based on the platform or if the game is registered.
*/
export const addAuthenticationTextsToLoadingContainer = (
loaderContainer: HTMLDivElement,
platform,
isGameRegistered
) => {
const textContainer: HTMLDivElement = document.createElement('div');
textContainer.id = 'authentication-container-texts';
textContainer.style.display = 'flex';
textContainer.style.flexDirection = 'column';
textContainer.style.width = '100%';
textContainer.style.justifyContent = 'center';
textContainer.style.alignItems = 'center';
textContainer.style.position = 'relative';
textContainer.style.zIndex = '3';
textContainer.style.fontSize = '11pt';
textContainer.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
const messages = getPlayerLoginMessages({
platform,
isGameRegistered,
});
const title = document.createElement('h1');
title.innerText = messages.title;
title.style.fontSize = '20pt';
title.style.fontWeight = 'bold';
const text1 = document.createElement('p');
text1.innerText = messages.text1;
const text2 = document.createElement('p');
text2.innerText = messages.text2;
textContainer.appendChild(title);
textContainer.appendChild(text1);
textContainer.appendChild(text2);
if (!isGameRegistered) {
// Remove the loader.
loaderContainer.innerHTML = '';
}
loaderContainer.prepend(textContainer);
return textContainer;
};
/**
* Helper to add the authentication link in case the window hasn't opened properly.
* Useful for Electron & Web platforms.
*/
export const addAuthenticationUrlToTextsContainer = (
onClick: () => void,
textContainer: HTMLDivElement
) => {
const link = document.createElement('a');
addTouchAndClickEventListeners(link, onClick);
link.innerText = 'Click here to authenticate';
link.style.color = '#0078d4';
link.style.textDecoration = 'none';
link.style.textDecoration = 'underline';
link.style.cursor = 'pointer';
textContainer.appendChild(link);
};
/**
* Creates a DOM element to display a dismissable banner.
*/
export const computeDismissableBanner = function (
onDismissBanner: () => void
): HTMLDivElement {
const divContainer = document.createElement('div');
divContainer.id = 'authenticated-banner';
divContainer.style.position = 'absolute';
divContainer.style.pointerEvents = 'all';
divContainer.style.backgroundColor = '#0E062D';
divContainer.style.top = '0px';
divContainer.style.height = '48px';
divContainer.style.left = '0px';
divContainer.style.width = '100%';
divContainer.style.padding = '6px 16px';
// Use zIndex 1 to make sure it is below the authentication iframe or webview.
divContainer.style.zIndex = '1';
divContainer.style.display = 'flex';
divContainer.style.flexDirection = 'row-reverse';
divContainer.style.justifyContent = 'space-between';
divContainer.style.alignItems = 'center';
divContainer.style.boxShadow = '0px 4px 4px rgba(0, 0, 0, 0.25)';
divContainer.style.fontSize = '11pt';
divContainer.style.color = '#FFFFFF';
divContainer.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
const _closeContainer: HTMLDivElement = document.createElement('div');
_closeContainer.style.cursor = 'pointer';
_closeContainer.style.display = 'flex';
_closeContainer.style.justifyContent = 'center';
_closeContainer.style.alignItems = 'center';
_closeContainer.style.zIndex = '3';
_closeContainer.style.marginRight = '32px';
_closeContainer.style.height = '100%';
addTouchAndClickEventListeners(_closeContainer, onDismissBanner);
const _close = document.createElement('img');
_close.setAttribute('width', '30px');
_close.setAttribute(
'src',
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIHZpZXdCb3g9IjAgMCAzMCAzMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTIzLjc1IDguMDEyNUwyMS45ODc1IDYuMjVMMTUgMTMuMjM3NUw4LjAxMjUgNi4yNUw2LjI1IDguMDEyNUwxMy4yMzc1IDE1TDYuMjUgMjEuOTg3NUw4LjAxMjUgMjMuNzVMMTUgMTYuNzYyNUwyMS45ODc1IDIzLjc1TDIzLjc1IDIxLjk4NzVMMTYuNzYyNSAxNUwyMy43NSA4LjAxMjVaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K'
);
_closeContainer.appendChild(_close);
divContainer.appendChild(_closeContainer);
return divContainer;
};
/**
* Creates a DOM element representing a banner for the user to know which account
* they're using and also to allow switching to another account.
*/
export const computeAuthenticatedBanner = function (
onOpenAuthenticationWindow: () => void,
onDismissBanner: () => void,
username: string | null
): HTMLDivElement {
const divContainer = computeDismissableBanner(onDismissBanner);
const playerUsername = username || 'Anonymous';
const _textContainer: HTMLDivElement = document.createElement('div');
const loggedText = document.createElement('p');
loggedText.id = 'loggedText';
loggedText.innerHTML = `<img style="margin-right:4px" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSI5IiB2aWV3Qm94PSIwIDAgOSA5IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNC4xNjY2NyAwQzEuODY2NjcgMCAwIDEuODY2NjcgMCA0LjE2NjY3QzAgNi40NjY2NyAxLjg2NjY3IDguMzMzMzMgNC4xNjY2NyA4LjMzMzMzQzYuNDY2NjcgOC4zMzMzMyA4LjMzMzMzIDYuNDY2NjcgOC4zMzMzMyA0LjE2NjY3QzguMzMzMzMgMS44NjY2NyA2LjQ2NjY3IDAgNC4xNjY2NyAwWk0zLjMzMzMzIDYuMjVMMS4yNSA0LjE2NjY3TDEuODM3NSAzLjU3OTE3TDMuMzMzMzMgNS4wNzA4M0w2LjQ5NTgzIDEuOTA4MzNMNy4wODMzMyAyLjVMMy4zMzMzMyA2LjI1WiIgZmlsbD0iIzAwQ0M4MyIvPgo8L3N2Zz4K" />
Logged as ${playerUsername}`;
loggedText.style.margin = '0px';
const changeAccountText = document.createElement('p');
changeAccountText.id = 'changeAccountText';
changeAccountText.innerText = `Click here to switch to another account.`;
changeAccountText.style.margin = '0px';
changeAccountText.style.marginTop = '4px';
changeAccountText.style.textDecoration = 'underline';
changeAccountText.style.cursor = 'pointer';
addTouchAndClickEventListeners(
changeAccountText,
onOpenAuthenticationWindow
);
_textContainer.appendChild(loggedText);
_textContainer.appendChild(changeAccountText);
divContainer.appendChild(_textContainer);
return divContainer;
};
/**
* Creates a DOM element representing a banner for the user to know
* they are not connected and to allow logging in.
*/
export const computeNotAuthenticatedBanner = function (
onOpenAuthenticationWindow: () => void,
onDismissBanner: () => void
): HTMLDivElement {
const divContainer = computeDismissableBanner(onDismissBanner);
const _textContainer: HTMLDivElement = document.createElement('div');
const loggedText = document.createElement('p');
loggedText.id = 'loggedText';
loggedText.innerHTML = `You are not authenticated.`;
loggedText.style.margin = '0px';
const changeAccountText = document.createElement('p');
changeAccountText.id = 'changeAccountText';
changeAccountText.innerText = `Click here to log in.`;
changeAccountText.style.margin = '0px';
changeAccountText.style.marginTop = '4px';
changeAccountText.style.textDecoration = 'underline';
changeAccountText.style.cursor = 'pointer';
addTouchAndClickEventListeners(
changeAccountText,
onOpenAuthenticationWindow
);
_textContainer.appendChild(loggedText);
_textContainer.appendChild(changeAccountText);
divContainer.appendChild(_textContainer);
return divContainer;
};
/**
* Create, display, and hide the logged in confirmation.
*/
export const displayLoggedInNotification = function (
domContainer: HTMLDivElement,
username: string
) {
showNotification(
domContainer,
'authenticated-notification',
`<img style="margin-right:4px" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSI5IiB2aWV3Qm94PSIwIDAgOSA5IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNC4xNjY2NyAwQzEuODY2NjcgMCAwIDEuODY2NjcgMCA0LjE2NjY3QzAgNi40NjY2NyAxLjg2NjY3IDguMzMzMzMgNC4xNjY2NyA4LjMzMzMzQzYuNDY2NjcgOC4zMzMzMyA4LjMzMzMzIDYuNDY2NjcgOC4zMzMzMyA0LjE2NjY3QzguMzMzMzMgMS44NjY2NyA2LjQ2NjY3IDAgNC4xNjY2NyAwWk0zLjMzMzMzIDYuMjVMMS4yNSA0LjE2NjY3TDEuODM3NSAzLjU3OTE3TDMuMzMzMzMgNS4wNzA4M0w2LjQ5NTgzIDEuOTA4MzNMNy4wODMzMyAyLjVMMy4zMzMzMyA2LjI1WiIgZmlsbD0iIzAwQ0M4MyIvPgo8L3N2Zz4K" />
Logged as ${username}`,
'success'
);
};
/**
* Create, display, and hide the logged in confirmation.
*/
export const displayLoggedOutNotification = function (
domContainer: HTMLDivElement
) {
showNotification(
domContainer,
'authenticated-notification',
`<img style="margin-right:4px" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSI5IiB2aWV3Qm94PSIwIDAgOSA5IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNC4xNjY2NyAwQzEuODY2NjcgMCAwIDEuODY2NjcgMCA0LjE2NjY3QzAgNi40NjY2NyAxLjg2NjY3IDguMzMzMzMgNC4xNjY2NyA4LjMzMzMzQzYuNDY2NjcgOC4zMzMzMyA4LjMzMzMzIDYuNDY2NjcgOC4zMzMzMyA0LjE2NjY3QzguMzMzMzMgMS44NjY2NyA2LjQ2NjY3IDAgNC4xNjY2NyAwWk0zLjMzMzMzIDYuMjVMMS4yNSA0LjE2NjY3TDEuODM3NSAzLjU3OTE3TDMuMzMzMzMgNS4wNzA4M0w2LjQ5NTgzIDEuOTA4MzNMNy4wODMzMyAyLjVMMy4zMzMzMyA2LjI1WiIgZmlsbD0iIzAwQ0M4MyIvPgo8L3N2Zz4K" />
Logged out`,
'success'
);
};
/**
* Create, display, and hide an error notification.
*/
export const displayErrorNotification = function (
domContainer: HTMLDivElement
) {
showNotification(
domContainer,
'error-notification',
'An error occurred while authenticating, please try again.',
'error'
);
};
/**
* Helper to show a notification to the user, that disappears automatically.
*/
export const showNotification = function (
domContainer: HTMLDivElement,
id: string,
content: string,
type: 'success' | 'error'
) {
const divContainer = document.createElement('div');
divContainer.id = id;
divContainer.style.position = 'absolute';
divContainer.style.pointerEvents = 'all';
divContainer.style.backgroundColor =
type === 'success' ? '#0E062D' : 'red';
divContainer.style.top = '12px';
divContainer.style.right = '16px';
divContainer.style.padding = '6px 32px 6px 6px';
// Use zIndex 1 to make sure it is below the authentication iframe or webview.
divContainer.style.zIndex = '1';
divContainer.style.display = 'flex';
divContainer.style.flexDirection = 'row-reverse';
divContainer.style.justifyContent = 'space-between';
divContainer.style.alignItems = 'center';
divContainer.style.boxShadow = '0px 4px 4px rgba(0, 0, 0, 0.25)';
divContainer.style.borderRadius = '4px';
divContainer.style.fontSize = '11pt';
divContainer.style.color = '#FFFFFF';
divContainer.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
try {
divContainer.animate(
[
{ transform: 'translateY(-30px)', opacity: 0 },
{ transform: 'translateY(0px)', opacity: 1 },
],
{
duration: 700,
easing: 'ease-out',
}
);
} catch {
logger.warn('Animation not supported, div will be fixed.');
}
const loggedText = document.createElement('p');
loggedText.id = 'loggedText';
loggedText.innerHTML = content;
loggedText.style.margin = '0px';
divContainer.appendChild(loggedText);
domContainer.appendChild(divContainer);
const animationTime = 700;
const notificationTime = 5000;
setTimeout(() => {
try {
divContainer.animate(
[
{ transform: 'translateY(0px)', opacity: 1 },
{ transform: 'translateY(-30px)', opacity: 0 },
],
{
duration: animationTime,
easing: 'ease-in',
}
);
} catch {
logger.warn('Animation not supported, div will be fixed.');
}
}, notificationTime);
// Use timeout because onanimationend listener does not work.
setTimeout(() => {
divContainer.remove();
}, notificationTime + animationTime);
};
/**
* Helper to add event listeners on a pressable/clickable element
* to work on both desktop and mobile.
*/
export const addTouchAndClickEventListeners = function (
element: HTMLElement,
action: () => void
) {
// Touch start event listener for mobile.
element.addEventListener('touchstart', (event) => {
action();
});
// Click event listener for desktop.
element.addEventListener('click', (event) => {
action();
});
};
}
}

View File

@@ -0,0 +1,739 @@
namespace gdjs {
declare var cordova: any;
const logger = new gdjs.Logger('Player Authentication');
const authComponents = gdjs.playerAuthenticationComponents;
export namespace playerAuthentication {
// Authentication information.
let _username: string | null = null;
let _userId: string | null = null;
let _userToken: string | null = null;
let _justLoggedIn = false;
let _checkedLocalStorage: boolean = false;
// Authentication display
let _authenticationWindow: Window | null = null; // For Web.
let _authenticationInAppWindow: Window | null = null; // For Cordova.
let _authenticationRootContainer: HTMLDivElement | null = null;
let _authenticationLoaderContainer: HTMLDivElement | null = null;
let _authenticationTextContainer: HTMLDivElement | null = null;
let _authenticationBanner: HTMLDivElement | null = null;
let _authenticationTimeoutId: NodeJS.Timeout | null = null;
// Communication methods.
let _authenticationMessageCallback:
| ((event: MessageEvent) => void)
| null = null;
let _cordovaAuthenticationMessageCallback:
| ((event: MessageEvent) => void)
| null = null;
let _websocket: WebSocket | null = null;
// Ensure that the condition "just logged in" is valid only for one frame.
gdjs.registerRuntimeScenePostEventsCallback(() => {
_justLoggedIn = false;
});
const getLocalStorageKey = (gameId: string) =>
`${gameId}_authenticatedUser`;
const getAuthWindowUrl = ({
runtimeGame,
gameId,
connectionId,
}: {
runtimeGame: gdjs.RuntimeGame;
gameId: string;
connectionId?: string;
}) =>
`https://liluo.io/auth?gameId=${gameId}${
connectionId ? `&connectionId=${connectionId}` : ''
}${
runtimeGame.isUsingGDevelopDevelopmentEnvironment() ? '&dev=true' : ''
}`;
/**
* Helper returning the platform.
*/
const getPlatform = (
runtimeScene: RuntimeScene
): 'electron' | 'cordova' | 'web' => {
const electron = runtimeScene.getGame().getRenderer().getElectron();
if (electron) {
return 'electron';
}
if (typeof cordova !== 'undefined') return 'cordova';
return 'web';
};
/**
* Returns true if a user token is present in the local storage.
*/
export const isAuthenticated = () => {
if (!_checkedLocalStorage) {
readAuthenticatedUserFromLocalStorage();
}
return _userToken !== null;
};
/**
* Returns true if the user just logged in.
* Useful to update username or trigger messages in the game.
*/
export const hasLoggedIn = () => _justLoggedIn;
/**
* Returns the username from the local storage.
*/
export const getUsername = () => {
if (!_checkedLocalStorage) {
readAuthenticatedUserFromLocalStorage();
}
return _username || '';
};
/**
* Returns the user token from the local storage.
*/
export const getUserToken = () => {
if (!_checkedLocalStorage) {
readAuthenticatedUserFromLocalStorage();
}
return _userToken || null;
};
/**
* Returns the username from the local storage.
*/
export const getUserId = () => {
if (!_checkedLocalStorage) {
readAuthenticatedUserFromLocalStorage();
}
return _userId || null;
};
/**
* Returns true if the game is registered, false otherwise.
* Useful to display a message to the user to register the game before logging in.
*/
const checkIfGameIsRegistered = (
runtimeGame: gdjs.RuntimeGame,
gameId: string,
tries: number = 0
): Promise<boolean> => {
const rootApi = runtimeGame.isUsingGDevelopDevelopmentEnvironment()
? 'https://api-dev.gdevelop.io'
: 'https://api.gdevelop.io';
const url = `${rootApi}/game/public-game/${gameId}`;
return fetch(url, { method: 'HEAD' }).then(
(response) => {
if (response.status !== 200) {
logger.warn(
`Error while fetching the game: ${response.status} ${response.statusText}`
);
// If the response is not 404, it may be a timeout, so retry a few times.
if (response.status === 404 || tries > 2) {
return false;
}
return checkIfGameIsRegistered(runtimeGame, gameId, tries + 1);
}
return true;
},
(err) => {
logger.error('Error while fetching game:', err);
return false;
}
);
};
/**
* Remove the user information from the local storage.
*/
export const logout = (runtimeScene: RuntimeScene) => {
_username = null;
_userToken = null;
_userId = null;
const gameId = gdjs.projectData.properties.projectUuid;
if (!gameId) {
logger.error('Missing game id in project properties.');
return;
}
window.localStorage.removeItem(getLocalStorageKey(gameId));
cleanUpAuthWindowAndCallbacks(runtimeScene);
removeAuthenticationBanner(runtimeScene);
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleAuthenticationError(
runtimeScene,
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
);
return;
}
authComponents.displayLoggedOutNotification(domElementContainer);
};
/**
* Retrieves the user information from the local storage, and store
* them in the extension variables.
*/
const readAuthenticatedUserFromLocalStorage = () => {
const gameId = gdjs.projectData.properties.projectUuid;
if (!gameId) {
logger.error('Missing game id in project properties.');
return;
}
const authenticatedUserStorageItem = window.localStorage.getItem(
getLocalStorageKey(gameId)
);
if (!authenticatedUserStorageItem) {
_checkedLocalStorage = true;
return;
}
const authenticatedUser = JSON.parse(authenticatedUserStorageItem);
_username = authenticatedUser.username;
_userId = authenticatedUser.userId;
_userToken = authenticatedUser.userToken;
_checkedLocalStorage = true;
};
/**
* Helper to be called on login or error.
* Removes all the UI and callbacks.
*/
const cleanUpAuthWindowAndCallbacks = (runtimeScene: RuntimeScene) => {
removeAuthenticationContainer(runtimeScene);
clearAuthenticationWindowTimeout();
if (_websocket) {
_websocket.close();
_websocket = null;
}
// If a new window was opened (web), close it.
if (_authenticationWindow) {
_authenticationWindow.close();
_authenticationWindow = null;
}
// If an in-app browser was used (cordova), close it.
if (_authenticationInAppWindow) {
_authenticationInAppWindow.close();
_authenticationInAppWindow = null;
}
};
/**
* When the websocket receives the authentication result, close all the
* authentication windows, display the notification and focus on the game.
*/
const handleLoggedInEvent = function (
runtimeScene: gdjs.RuntimeScene,
userId: string,
username: string | null,
userToken: string
) {
if (!username) {
logger.warn('The authenticated player does not have a username');
}
_username = username;
_userId = userId;
_userToken = userToken;
_justLoggedIn = true;
const gameId = gdjs.projectData.properties.projectUuid;
if (!gameId) {
logger.error('Missing game id in project properties.');
return;
}
window.localStorage.setItem(
getLocalStorageKey(gameId),
JSON.stringify({
username: _username,
userId: _userId,
userToken: _userToken,
})
);
cleanUpAuthWindowAndCallbacks(runtimeScene);
removeAuthenticationBanner(runtimeScene);
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleAuthenticationError(
runtimeScene,
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
);
return;
}
authComponents.displayLoggedInNotification(
domElementContainer,
_username || 'Anonymous'
);
focusOnGame(runtimeScene);
};
/**
* Reads the event sent by the authentication window and
* display the appropriate banner.
*/
const receiveMessageFromAuthenticationWindow = function (
runtimeScene: gdjs.RuntimeScene,
event: MessageEvent,
{ checkOrigin }: { checkOrigin: boolean }
) {
const allowedOrigin = 'https://liluo.io';
// Check origin of message.
if (checkOrigin && event.origin !== allowedOrigin) {
throw new Error(`Unexpected origin: ${event.origin}`);
}
// Check that message is not malformed.
if (!event.data.id) {
throw new Error('Malformed message');
}
// Handle message.
switch (event.data.id) {
case 'authenticationResult': {
if (!(event.data.body && event.data.body.token)) {
throw new Error('Malformed message.');
}
handleLoggedInEvent(
runtimeScene,
event.data.body.userId,
event.data.body.username,
event.data.body.token
);
break;
}
}
};
/**
* Handle any error that can occur as part of the authentication process.
*/
const handleAuthenticationError = function (
runtimeScene: gdjs.RuntimeScene,
message: string
) {
logger.error(message);
cleanUpAuthWindowAndCallbacks(runtimeScene);
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleAuthenticationError(
runtimeScene,
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
);
return;
}
authComponents.displayErrorNotification(domElementContainer);
focusOnGame(runtimeScene);
};
/**
* If after 5min, no message has been received from the authentication window,
* show a notification and remove the authentication container.
*/
const startAuthenticationWindowTimeout = (
runtimeScene: gdjs.RuntimeScene
) => {
clearAuthenticationWindowTimeout();
const time = 12 * 60 * 1000; // 12 minutes, in case the user needs time to authenticate.
_authenticationTimeoutId = setTimeout(() => {
logger.info(
'Authentication window did not send message in time. Closing it.'
);
cleanUpAuthWindowAndCallbacks(runtimeScene);
focusOnGame(runtimeScene);
}, time);
};
/**
* Clear the authentication window timeout.
* Useful when:
* - the authentication succeeded
* - the authentication window is closed
*/
const clearAuthenticationWindowTimeout = () => {
if (_authenticationTimeoutId) clearTimeout(_authenticationTimeoutId);
};
/**
* Action to display the banner to the user, depending on their authentication status.
*/
export const displayAuthenticationBanner = function (
runtimeScene: gdjs.RuntimeScene
) {
if (_authenticationBanner) {
// Banner already displayed, ensure it's visible.
_authenticationBanner.style.opacity = '1';
return;
}
if (!_checkedLocalStorage) {
readAuthenticatedUserFromLocalStorage();
}
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleAuthenticationError(
runtimeScene,
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
);
return;
}
const onDismissBanner = () => {
removeAuthenticationBanner(runtimeScene);
};
const onOpenAuthenticationWindow = () => {
openAuthenticationWindow(runtimeScene);
};
// We display the corresponding banner depending on the authentication status.
_authenticationBanner = _userToken
? authComponents.computeAuthenticatedBanner(
onOpenAuthenticationWindow,
onDismissBanner,
_username
)
: authComponents.computeNotAuthenticatedBanner(
onOpenAuthenticationWindow,
onDismissBanner
);
domElementContainer.appendChild(_authenticationBanner);
};
/**
* Helper to handle authentication window on Electron.
* We open a new window, and create a websocket to know when the user is logged in.
*/
const openAuthenticationWindowForElectron = (
runtimeScene: gdjs.RuntimeScene,
gameId: string
) => {
const wsPlayApi = runtimeScene
.getGame()
.isUsingGDevelopDevelopmentEnvironment()
? 'wss://api-ws-dev.gdevelop.io/play'
: 'wss://api-ws.gdevelop.io/play';
_websocket = new WebSocket(wsPlayApi);
_websocket.onopen = () => {
// When socket is open, ask for the connectionId, so that we can open the authentication window.
if (_websocket) {
_websocket.send(JSON.stringify({ action: 'getConnectionId' }));
}
};
_websocket.onerror = () => {
handleAuthenticationError(
runtimeScene,
'Error while connecting to the authentication server.'
);
};
_websocket.onmessage = (event) => {
if (event.data) {
const messageContent = JSON.parse(event.data);
switch (messageContent.type) {
case 'authenticationResult': {
const messageData = messageContent.data;
handleLoggedInEvent(
runtimeScene,
messageData.userId,
messageData.username,
messageData.token
);
break;
}
case 'connectionId': {
const messagegeData = messageContent.data;
const connectionId = messagegeData.connectionId;
if (!connectionId) {
logger.error('No connectionId received');
return;
}
const targetUrl = getAuthWindowUrl({
runtimeGame: runtimeScene.getGame(),
gameId,
connectionId,
});
const electron = runtimeScene
.getGame()
.getRenderer()
.getElectron();
const openWindow = () => electron.shell.openExternal(targetUrl);
openWindow();
// Add the link to the window in case a popup blocker is preventing the window from opening.
if (_authenticationTextContainer) {
authComponents.addAuthenticationUrlToTextsContainer(
openWindow,
_authenticationTextContainer
);
}
break;
}
}
}
};
};
/**
* Helper to handle authentication window on Cordova.
* We open an InAppBrowser window, and listen to messages posted on this window.
*/
const openAuthenticationWindowForCordova = (
runtimeScene: gdjs.RuntimeScene,
gameId: string
) => {
const targetUrl = getAuthWindowUrl({
runtimeGame: runtimeScene.getGame(),
gameId,
});
_authenticationInAppWindow = cordova.InAppBrowser.open(
targetUrl,
'authentication',
'location=yes' // location=yes is important to show the URL bar to the user.
);
// Listen to messages posted on the authentication window, so that we can
// know when the user is authenticated.
if (_authenticationInAppWindow) {
_cordovaAuthenticationMessageCallback = (event: MessageEvent) => {
receiveMessageFromAuthenticationWindow(runtimeScene, event, {
checkOrigin: false, // For Cordova we don't check the origin, as the message is read from the InAppBrowser directly.
});
};
_authenticationInAppWindow.addEventListener(
'message',
_cordovaAuthenticationMessageCallback,
true
);
}
};
/**
* Helper to handle authentication window on web.
* We open a new window, and listen to messages posted back to the game window.
*/
const openAuthenticationWindowForWeb = (
runtimeScene: gdjs.RuntimeScene,
gameId: string
) => {
// If we're on a browser, open a new window.
const targetUrl = getAuthWindowUrl({
runtimeGame: runtimeScene.getGame(),
gameId,
});
// Listen to messages posted by the authentication window, so that we can
// know when the user is authenticated.
_authenticationMessageCallback = (event: MessageEvent) => {
receiveMessageFromAuthenticationWindow(runtimeScene, event, {
checkOrigin: true,
});
};
window.addEventListener('message', _authenticationMessageCallback, true);
const left = screen.width / 2 - 500 / 2;
const top = screen.height / 2 - 600 / 2;
const windowFeatures = `left=${left},top=${top},width=500,height=600`;
const openWindow = () =>
window.open(targetUrl, 'authentication', windowFeatures);
_authenticationWindow = openWindow();
// Add the link to the window in case a popup blocker is preventing the window from opening.
if (_authenticationTextContainer) {
authComponents.addAuthenticationUrlToTextsContainer(
openWindow,
_authenticationTextContainer
);
}
};
/**
* Action to display the authentication window to the user.
*/
export const openAuthenticationWindow = function (
runtimeScene: gdjs.RuntimeScene
) {
// Create the authentication container for the player to wait.
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleAuthenticationError(
runtimeScene,
"The div element covering the game couldn't be found, the authentication window cannot be displayed."
);
return;
}
const onAuthenticationContainerDismissed = () => {
cleanUpAuthWindowAndCallbacks(runtimeScene);
displayAuthenticationBanner(runtimeScene);
};
const _gameId = gdjs.projectData.properties.projectUuid;
if (!_gameId) {
handleAuthenticationError(
runtimeScene,
'The game ID is missing, the authentication window cannot be opened.'
);
return;
}
// If the banner is displayed, hide it, so that it can be shown again if the user closes the window.
if (_authenticationBanner) _authenticationBanner.style.opacity = '0';
const platform = getPlatform(runtimeScene);
const {
rootContainer,
loaderContainer,
} = authComponents.computeAuthenticationContainer(
onAuthenticationContainerDismissed
);
_authenticationRootContainer = rootContainer;
_authenticationLoaderContainer = loaderContainer;
// Display the authentication window right away, to show a loader
// while the call for game registration is happening.
domElementContainer.appendChild(_authenticationRootContainer);
// If the game is registered, open the authentication window.
// Otherwise, open the window indicating that the game is not registered.
checkIfGameIsRegistered(runtimeScene.getGame(), _gameId)
.then((isGameRegistered) => {
if (_authenticationLoaderContainer) {
_authenticationTextContainer = authComponents.addAuthenticationTextsToLoadingContainer(
_authenticationLoaderContainer,
platform,
isGameRegistered
);
}
if (isGameRegistered) {
startAuthenticationWindowTimeout(runtimeScene);
// Based on which platform the game is running, we open the authentication window
// with a different window, with or without a websocket.
switch (platform) {
case 'electron':
openAuthenticationWindowForElectron(runtimeScene, _gameId);
break;
case 'cordova':
openAuthenticationWindowForCordova(runtimeScene, _gameId);
break;
case 'web':
default:
openAuthenticationWindowForWeb(runtimeScene, _gameId);
break;
}
}
})
.catch((error) => {
handleAuthenticationError(
runtimeScene,
'Error while checking if the game is registered.'
);
console.error(error);
});
};
/**
* Condition to check if the window is open, so that the game can be paused in the background.
*/
export const isAuthenticationWindowOpen = function (): boolean {
return !!_authenticationRootContainer;
};
/**
* Remove the container displaying the authentication window and the callback.
*/
export const removeAuthenticationContainer = function (
runtimeScene: gdjs.RuntimeScene
) {
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
logger.info(
"The div element covering the game couldn't be found, the authentication must be already closed."
);
return;
}
// Remove the authentication root container.
if (_authenticationRootContainer) {
domElementContainer.removeChild(_authenticationRootContainer);
}
// Remove the authentication callbacks.
if (_authenticationMessageCallback) {
window.removeEventListener(
'message',
_authenticationMessageCallback,
true
);
_authenticationMessageCallback = null;
// No need to detach the callback from the InAppBrowser, as it's destroyed when the window is closed.
_cordovaAuthenticationMessageCallback = null;
}
_authenticationRootContainer = null;
_authenticationLoaderContainer = null;
_authenticationTextContainer = null;
};
/**
* Remove the banner displaying the authentication status.
*/
const removeAuthenticationBanner = function (
runtimeScene: gdjs.RuntimeScene
) {
if (!_authenticationBanner) {
logger.info(
"The authentication banner couldn't be found, the authentication banner must be already closed."
);
return;
}
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
logger.info(
"The div element covering the game couldn't be found, the authentication must be already closed."
);
return;
}
domElementContainer.removeChild(_authenticationBanner);
_authenticationBanner = null;
};
/**
* Focus on game canvas to allow user to interact with it.
*/
const focusOnGame = function (runtimeScene: gdjs.RuntimeScene) {
const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas();
if (gameCanvas) gameCanvas.focus();
};
}
}

View File

@@ -612,7 +612,7 @@ module.exports = {
this.update();
}
static getThumbnail(project, resourcesLoader, object) {
static getThumbnail(project, resourcesLoader, objectConfiguration) {
return 'JsPlatform/Extensions/text_input.svg';
}

View File

@@ -1007,7 +1007,7 @@ module.exports = {
RenderedTileMapInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'JsPlatform/Extensions/tile_map.svg';
};
@@ -1249,7 +1249,7 @@ module.exports = {
RenderedCollisionMaskInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'JsPlatform/Extensions/tile_map_collision_mask24.svg';
};

View File

@@ -124,7 +124,7 @@ describe('gdjs.TileMapCollisionMaskRuntimeObject', function () {
index < 200 && tileMap._collisionTileMap.getDimensionX() === 0;
index++
) {
await delay(5);
await delay(10);
}
if (tileMap._collisionTileMap.getDimensionX() === 0) {
throw new Error('Timeout reading the tile map JSON file.');

View File

@@ -561,7 +561,7 @@ module.exports = {
RenderedVideoObjectInstance.getThumbnail = function (
project,
resourcesLoader,
object
objectConfiguration
) {
return 'JsPlatform/Extensions/videoicon24.png';
};

View File

@@ -162,6 +162,9 @@ bool ExporterHelper::ExportProjectForPixiPreview(
.SetStringValue(options.websocketDebuggerServerPort);
runtimeGameOptions.AddChild("electronRemoteRequirePath")
.SetStringValue(options.electronRemoteRequirePath);
if (options.isDevelopmentEnvironment) {
runtimeGameOptions.AddChild("environment").SetStringValue("dev");
}
// Pass in the options the list of scripts files - useful for hot-reloading.
auto &scriptFilesElement = runtimeGameOptions.AddChild("scriptFiles");

View File

@@ -37,6 +37,7 @@ struct PreviewExportOptions {
useWindowMessageDebuggerClient(false),
projectDataOnlyExport(false),
fullLoadingScreen(false),
isDevelopmentEnvironment(false),
nonRuntimeScriptsCacheBurst(0){};
/**
@@ -105,6 +106,15 @@ struct PreviewExportOptions {
return *this;
}
/**
* \brief Set if the export should consider to be in a development environment
* of GDevelop (the game should use GDevelop development APIs).
*/
PreviewExportOptions &SetIsDevelopmentEnvironment(bool enable) {
isDevelopmentEnvironment = enable;
return *this;
}
/**
* \brief If set to a non zero value, the exported script URLs will have an
* extra search parameter added (with the given value) to ensure browser cache
@@ -137,6 +147,7 @@ struct PreviewExportOptions {
std::map<gd::String, int> includeFileHashes;
bool projectDataOnlyExport;
bool fullLoadingScreen;
bool isDevelopmentEnvironment;
unsigned int nonRuntimeScriptsCacheBurst;
gd::String electronRemoteRequirePath;
};

View File

@@ -41,6 +41,12 @@ namespace gdjs {
* `@electron/remote` in the game engine and extensions.
*/
electronRemoteRequirePath?: string;
/**
* If set, the game should use the specified environment for making calls
* to GDevelop APIs ("dev" = development APIs).
*/
environment?: 'dev';
};
/**
@@ -135,6 +141,12 @@ namespace gdjs {
this._isPreview = this._options.isPreview || false;
this._sessionId = null;
this._playerId = null;
if (this.isUsingGDevelopDevelopmentEnvironment()) {
logger.info(
'This game will run on the development version of GDevelop APIs.'
);
}
}
/**
@@ -884,6 +896,15 @@ namespace gdjs {
return this._isPreview;
}
/**
* Check if the game should call GDevelop development APIs or not.
*
* Unless you are contributing to GDevelop, avoid using this.
*/
isUsingGDevelopDevelopmentEnvironment(): boolean {
return this._options.environment === 'dev';
}
/**
* Gets an extension property from the project data.
* @param extensionName The extension name.

View File

@@ -194,7 +194,13 @@ namespace gdjs {
return obj;
case 'array':
const arr: any[] = [];
for (const item of this._childrenArray) arr.push(item.toJSObject());
for (const item of this._childrenArray) {
// item can be undefined if there are empty elements in the array.
// A variable can have empty items in its children array if one inserts
// a variable at an index greater than highest index. All the array elements
// in the gap will be empty elements.
arr.push(item === undefined ? undefined : item.toJSObject());
}
return arr;
}
}

View File

@@ -1,5 +1,8 @@
module.exports = function (config) {
const testFiles = ['./Extensions/**/tests/**.spec.js', './GDJS/tests/tests/**/*.js'];
const testFiles = [
'./Extensions/**/tests/**.spec.js',
'./GDJS/tests/tests/**/*.js',
];
const benchmarkFiles = [
'./GDJS/tests/benchmarks/init.js',
@@ -25,8 +28,7 @@ module.exports = function (config) {
},
basePath: '../..',
proxies: {
'/base/tests-utils/':
'/base/GDJS/tests/tests-utils/',
'/base/tests-utils/': '/base/GDJS/tests/tests-utils/',
},
files: [
'./GDJS/tests/node_modules/expect.js/index.js',
@@ -82,7 +84,8 @@ module.exports = function (config) {
'./newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventory.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventorytools.js',
{
pattern: './newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.wasm',
pattern:
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.wasm',
watched: true,
included: false,
served: true,
@@ -92,6 +95,8 @@ module.exports = function (config) {
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/physics2runtimebehavior.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/physics2tools.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Leaderboards/leaderboardstools.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/PlayerAuthentication/playerauthenticationtools.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/PlayerAuthentication/playerauthenticationcomponents.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject-pixi-renderer.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightobstacleruntimebehavior.js',

View File

@@ -1218,6 +1218,7 @@ interface ExpressionMetadata {
[Const, Ref] DOMString GetDescription();
[Const, Ref] DOMString GetGroup();
[Const, Ref] DOMString GetSmallIconFilename();
[Const, Ref] DOMString GetHelpPath();
boolean IsShown();
boolean IsPrivate();
[Ref] ParameterMetadata GetParameter(unsigned long id);
@@ -2321,6 +2322,8 @@ interface EventsBasedObject {
[Const, Ref] DOMString GetName();
[Ref] EventsBasedObject SetFullName([Const] DOMString fullName);
[Const, Ref] DOMString GetFullName();
[Ref] EventsBasedObject SetDefaultName([Const] DOMString defaultName);
[Const, Ref] DOMString GetDefaultName();
[Ref] EventsFunctionsContainer GetEventsFunctions();
[Ref] NamedPropertyDescriptorsList GetPropertyDescriptors();
@@ -2940,6 +2943,7 @@ interface PreviewExportOptions {
[Ref] PreviewExportOptions SetIncludeFileHash([Const] DOMString includeFile, long hash);
[Ref] PreviewExportOptions SetProjectDataOnlyExport(boolean enable);
[Ref] PreviewExportOptions SetFullLoadingScreen(boolean enable);
[Ref] PreviewExportOptions SetIsDevelopmentEnvironment(boolean enable);
[Ref] PreviewExportOptions SetNonRuntimeScriptsCacheBurst(unsigned long value);
[Ref] PreviewExportOptions SetElectronRemoteRequirePath([Const] DOMString electronRemoteRequirePath);
};

View File

@@ -9,7 +9,7 @@ module.exports = {
makeTestExtensions: (gd) => {
const platform = gd.JsPlatform.get();
{
const declareFakeAdmod = () => {
const extension = new gd.PlatformExtension();
extension
.setExtensionInformation(
@@ -69,8 +69,10 @@ module.exports = {
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
}
{
};
declareFakeAdmod();
const declareFakeObjectWithUnsupportedCapability = () => {
const extension = new gd.PlatformExtension();
extension.setExtensionInformation(
'FakeObjectWithUnsupportedCapability',
@@ -139,8 +141,10 @@ module.exports = {
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
}
{
};
declareFakeObjectWithUnsupportedCapability();
const declareFakeObjectWithAsyncAction = () => {
const extension = new gd.PlatformExtension();
extension.setExtensionInformation(
'FakeObjectWithAsyncAction',
@@ -225,8 +229,10 @@ module.exports = {
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
}
{
};
declareFakeObjectWithAsyncAction();
const declareFakeOptionallyAsyncAction = () => {
const extension = new gd.PlatformExtension();
extension.setExtensionInformation(
'FakeOptionallyAsyncAction',
@@ -253,6 +259,111 @@ module.exports = {
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
}
};
declareFakeOptionallyAsyncAction();
const declareFakeBehaviorWithSharedData = () => {
const extension = new gd.PlatformExtension();
extension.setExtensionInformation(
'FakeBehaviorWithSharedData',
'Fake behavior with shared data',
'Fake behavior with shared data',
'',
'MIT'
);
// Declare a behavior with shared data between the behaviors
// In addition to the usual behavior:
// Create a new gd.BehaviorSharedDataJsImplementation object and implement the methods
// that are called to get and set the properties of the shared data.
const dummyBehaviorWithSharedData = new gd.BehaviorJsImplementation();
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
dummyBehaviorWithSharedData.updateProperty = function (
behaviorContent,
propertyName,
newValue
) {
if (propertyName === 'MyBehaviorProperty') {
behaviorContent.setStringAttribute('MyBehaviorProperty', newValue);
return true;
}
return false;
};
dummyBehaviorWithSharedData.getProperties = function (behaviorContent) {
const behaviorProperties = new gd.MapStringPropertyDescriptor();
behaviorProperties
.getOrCreate('MyBehaviorProperty')
.setValue(behaviorContent.getStringAttribute('MyBehaviorProperty'));
return behaviorProperties;
};
dummyBehaviorWithSharedData.initializeContent = function (
behaviorContent
) {
behaviorContent.setStringAttribute(
'MyBehaviorProperty',
'Initial value 1'
);
};
const sharedData = new gd.BehaviorSharedDataJsImplementation();
sharedData.updateProperty = function (
sharedContent,
propertyName,
newValue
) {
if (propertyName === 'MySharedProperty') {
sharedContent.setStringAttribute('MySharedProperty', newValue);
return true;
}
return false;
};
sharedData.getProperties = function (sharedContent) {
const sharedProperties = new gd.MapStringPropertyDescriptor();
sharedProperties
.getOrCreate('MySharedProperty')
.setValue(sharedContent.getStringAttribute('MySharedProperty'));
return sharedProperties;
};
sharedData.initializeContent = function (behaviorContent) {
behaviorContent.setStringAttribute(
'MySharedProperty',
'Initial shared value 1'
);
};
extension
.addBehavior(
'DummyBehaviorWithSharedData',
'Dummy behavior with shared data for testing',
'DummyBehaviorWithSharedData',
'Do nothing but use shared data.',
'',
'CppPlatform/Extensions/topdownmovementicon.png',
'DummyBehaviorWithSharedData',
dummyBehaviorWithSharedData,
sharedData
)
.setIncludeFile(
'Extensions/ExampleJsExtension/dummywithshareddataruntimebehavior.js'
)
// You can optionally include more than one file when the behavior is used:
.addIncludeFile(
'Extensions/ExampleJsExtension/examplejsextensiontools.js'
);
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
};
declareFakeBehaviorWithSharedData();
},
};

View File

@@ -49,6 +49,7 @@ describe('libGD.js - GDJS project serialization tests', function () {
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
it('should keep TiledSpriteObject configuration after a save and reload', function () {
@@ -84,6 +85,7 @@ describe('libGD.js - GDJS project serialization tests', function () {
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
it('should keep PanelSpriteObject configuration after a save and reload', function () {
@@ -119,6 +121,7 @@ describe('libGD.js - GDJS project serialization tests', function () {
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
it('should keep ShapePainterObject configuration after a save and reload', function () {
@@ -155,6 +158,7 @@ describe('libGD.js - GDJS project serialization tests', function () {
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
it('should keep ParticleEmitterObject configuration after a save and reload', function () {
@@ -190,5 +194,112 @@ describe('libGD.js - GDJS project serialization tests', function () {
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
it('should set behavior properties default values', function () {
const project = gd.ProjectHelper.createNewGDJSProject();
const layout = project.insertNewLayout('Scene', 0);
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const behavior = object.addNewBehavior(
project,
'FakeBehaviorWithSharedData::DummyBehaviorWithSharedData',
'DummyBehaviorWithSharedData'
);
expect(behavior.getProperties().get('MyBehaviorProperty').getValue()).toBe(
'Initial value 1'
);
});
it('should keep behavior properties values after a save and reload', function () {
const checkConfiguration = (project) => {
const layout = project.getLayout('Scene');
const object = layout.getObject('MyObject');
const behavior = object.getBehavior('DummyBehaviorWithSharedData');
expect(
behavior.getProperties().get('MyBehaviorProperty').getValue()
).toBe('123');
};
const serializerElement = new gd.SerializerElement();
{
const project = gd.ProjectHelper.createNewGDJSProject();
const layout = project.insertNewLayout('Scene', 0);
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const behavior = object.addNewBehavior(
project,
'FakeBehaviorWithSharedData::DummyBehaviorWithSharedData',
'DummyBehaviorWithSharedData'
);
behavior.updateProperty('MyBehaviorProperty', '123');
checkConfiguration(project);
project.serializeTo(serializerElement);
}
{
const project = gd.ProjectHelper.createNewGDJSProject();
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
it('should set behavior shared properties default values', function () {
const project = gd.ProjectHelper.createNewGDJSProject();
const layout = project.insertNewLayout('Scene', 0);
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const behavior = object.addNewBehavior(
project,
'FakeBehaviorWithSharedData::DummyBehaviorWithSharedData',
'DummyBehaviorWithSharedData'
);
layout.updateBehaviorsSharedData(project);
const sharedData = layout.getBehaviorSharedData(
'DummyBehaviorWithSharedData'
);
expect(sharedData.getProperties().get('MySharedProperty').getValue()).toBe(
'Initial shared value 1'
);
});
it('should keep behavior shared properties values after a save and reload', function () {
const checkConfiguration = (project) => {
const layout = project.getLayout('Scene');
const object = layout.getObject('MyObject');
const sharedData = layout.getBehaviorSharedData(
'DummyBehaviorWithSharedData'
);
expect(
sharedData.getProperties().get('MySharedProperty').getValue()
).toBe('123');
};
const serializerElement = new gd.SerializerElement();
{
const project = gd.ProjectHelper.createNewGDJSProject();
const layout = project.insertNewLayout('Scene', 0);
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const behavior = object.addNewBehavior(
project,
'FakeBehaviorWithSharedData::DummyBehaviorWithSharedData',
'DummyBehaviorWithSharedData'
);
layout.updateBehaviorsSharedData(project);
const sharedData = layout.getBehaviorSharedData(
'DummyBehaviorWithSharedData'
);
sharedData.updateProperty('MySharedProperty', '123');
checkConfiguration(project);
project.serializeTo(serializerElement);
}
{
const project = gd.ProjectHelper.createNewGDJSProject();
project.unserializeFrom(serializerElement);
checkConfiguration(project);
}
serializerElement.delete();
});
});

View File

@@ -7,6 +7,8 @@ declare class gdEventsBasedObject extends gdObjectsContainer {
getName(): string;
setFullName(fullName: string): gdEventsBasedObject;
getFullName(): string;
setDefaultName(defaultName: string): gdEventsBasedObject;
getDefaultName(): string;
getEventsFunctions(): gdEventsFunctionsContainer;
getPropertyDescriptors(): gdNamedPropertyDescriptorsList;
serializeTo(element: gdSerializerElement): void;

View File

@@ -6,6 +6,7 @@ declare class gdExpressionMetadata {
getDescription(): string;
getGroup(): string;
getSmallIconFilename(): string;
getHelpPath(): string;
isShown(): boolean;
isPrivate(): boolean;
getParameter(id: number): gdParameterMetadata;

View File

@@ -8,6 +8,7 @@ declare class gdPreviewExportOptions {
setIncludeFileHash(includeFile: string, hash: number): gdPreviewExportOptions;
setProjectDataOnlyExport(enable: boolean): gdPreviewExportOptions;
setFullLoadingScreen(enable: boolean): gdPreviewExportOptions;
setIsDevelopmentEnvironment(enable: boolean): gdPreviewExportOptions;
setNonRuntimeScriptsCacheBurst(value: number): gdPreviewExportOptions;
setElectronRemoteRequirePath(electronRemoteRequirePath: string): gdPreviewExportOptions;
delete(): void;

View File

@@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.55421 16.3457H5.75283H10.9997H11.0308C11.6104 17.9018 13.1322 19 14.8905 19C17.1386 19 19 17.2049 19 14.953C19 13.0339 17.6429 11.441 15.8458 11.0169L15.653 10.6547C14.861 9.16722 13.4178 8.12702 11.733 7.82892C12.4444 7.10695 12.885 6.12164 12.885 5.02698C12.885 2.7839 11.035 1 8.79595 1C6.55685 1 4.70688 2.7839 4.70688 5.02698C4.70688 6.1017 5.13156 7.071 5.82024 7.7892C3.11649 8.14324 1 10.4207 1 13.2058V14.953C1 15.7506 1.65379 16.3457 2.40071 16.3457H4.55421H4.75283H5.55421ZM15.4649 14.7289L15.4649 14.7289C15.4649 14.7289 15.4649 14.7289 15.4649 14.7289Z" fill="white" stroke="#29ABE1" stroke-width="2" />
<path d="M2 13.2058V14.953C2 15.1729 2.1803 15.3457 2.40071 15.3457H4.75283V13.3039C4.75283 13.084 4.93313 12.9113 5.15352 12.9113C5.37392 12.9113 5.55421 13.084 5.55421 13.3039V15.3457H10.9997C10.9877 15.2161 10.9797 15.0866 10.9797 14.953C10.9797 12.8799 12.6707 11.1836 14.7703 11.1247C14.001 9.67979 12.4703 8.74137 10.7313 8.74137H6.55597C4.04357 8.74137 2 10.7439 2 13.2058Z" fill="white" />
<path d="M18 14.953C18 13.2764 16.6055 11.9061 14.8905 11.9061C13.1755 11.9061 11.7811 13.2764 11.7811 14.953C11.7811 16.6336 13.1755 18 14.8905 18C16.6055 18 18 16.6336 18 14.953ZM16.1648 14.5683L14.8104 15.8954C14.7343 15.97 14.6341 16.0093 14.5259 16.0093C14.4217 16.0093 14.3176 15.97 14.2454 15.8954L13.6163 15.2789C13.46 15.1258 13.46 14.8784 13.6163 14.7253C13.7726 14.5722 14.025 14.5722 14.1813 14.7253L14.5259 15.063L15.5958 14.0146C15.752 13.8615 16.0085 13.8615 16.1648 14.0146C16.321 14.1677 16.321 14.4151 16.1648 14.5683Z" fill="#2B3990" />
<path d="M11.885 5.02698C11.885 6.69874 10.502 8.05397 8.79595 8.05397C7.0899 8.05397 5.70688 6.69874 5.70688 5.02698C5.70688 3.35523 7.0899 2 8.79595 2C10.502 2 11.885 3.35523 11.885 5.02698Z" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -85,6 +85,108 @@ const getAllExtensionAndExtensionShortHeaders = async () => {
return extensions;
};
const groupBy = (array, getKey) => {
const table = {};
for (const element of array) {
const key = getKey(element);
let group = table[key];
if (!group) {
group = [];
table[key] = group;
}
group.push(element);
}
return table;
}
const sortKeys = (table) => {
const sortedTable = {};
for (const key of Object.keys(table).sort()) {
sortedTable[key] = table[key];
}
return sortedTable;
}
const createExtensionReferencePage = async (extension, extensionShortHeader, isCommunity) => {
const folderName = getExtensionFolderName(extension.name);
const referencePageUrl = `${gdevelopWikiUrlRoot}/extensions/${folderName}/reference`;
const helpPageUrl = getHelpLink(extension.helpPath) || referencePageUrl;
const authorNamesWithLinks = generateAuthorNamesWithLinks(
extensionShortHeader.authors || []
);
const referencePageContent =
`# ${extension.fullName}` +
'\n\n' +
generateSvgImageIcon(extension.previewIconUrl) +
'\n' +
`${extension.shortDescription}\n` +
'\n' +
`**Authors and contributors** to this community extension: ${authorNamesWithLinks}.\n` +
'\n' +
(isCommunity ? `<note important>
This is an extension made by a community member — but not reviewed
by the GDevelop extension team. As such, we can't guarantee it
meets all the quality standards of official extensions. In case of
doubt, contact the author to know more about what the extension
does or inspect its content before using it.
</note>\n\n` : '') +
'---\n' +
'\n' +
convertMarkdownToDokuWikiMarkdown(extension.description) +
'\n' +
(extension.helpPath ? `\n[[${helpPageUrl}|Read more...]]\n` : ``) +
generateExtensionFooterText(extension.fullName);
const extensionReferenceFilePath = path.join(
extensionsRootPath,
folderName,
'reference.txt'
);
await fs.mkdir(path.dirname(extensionReferenceFilePath), {
recursive: true,
});
await fs.writeFile(extensionReferenceFilePath, referencePageContent);
console.info(` File generated: ${extensionReferenceFilePath}`);
};
const generateExtensionSection = (extension, extensionShortHeader) => {
const folderName = getExtensionFolderName(extension.name);
const referencePageUrl = `${gdevelopWikiUrlRoot}/extensions/${folderName}/reference`;
const helpPageUrl = getHelpLink(extension.helpPath) || referencePageUrl;
return (
`#### ${extension.fullName}\n` +
// Use the `&.png?` syntax to force Dokuwiki to display the image.
// See https://www.dokuwiki.org/images.
generateSvgImageIcon(extension.previewIconUrl) +
'\n' +
extension.shortDescription +
'\n\n' +
// Link to help page or to reference if none.
`[[${helpPageUrl}|Read more...]]` +
(helpPageUrl !== referencePageUrl
? ` ([[${referencePageUrl}|reference]])`
: '') +
'\n\n');
};
const generateAllExtensionsSections = (extensionsAndExtensionShortHeaders) => {
let extensionSectionsContent = "";
const extensionsByCategory = sortKeys(groupBy(
extensionsAndExtensionShortHeaders,
pair => pair.extension.category || 'General'));
for (const category in extensionsByCategory) {
const extensions = extensionsByCategory[category];
extensionSectionsContent += `### ${category}\n\n`;
for (const { extension, extensionShortHeader } of extensions) {
extensionSectionsContent += generateExtensionSection(extension, extensionShortHeader);
}
}
return extensionSectionsContent;
}
(async () => {
try {
console.info(` Loading all community extensions...`);
@@ -98,60 +200,38 @@ GDevelop is built in a flexible way. In addition to [[gdevelop5:all-features|cor
`;
const reviewedExtensionsAndExtensionShortHeaders =
extensionsAndExtensionShortHeaders.filter(
pair => pair.extensionShortHeader.tier !== 'community');
const communityExtensionsAndExtensionShortHeaders =
extensionsAndExtensionShortHeaders.filter(
pair => pair.extensionShortHeader.tier === 'community');
indexPageContent += '## Reviewed extensions\n\n';
for (const {
extension,
extensionShortHeader,
} of extensionsAndExtensionShortHeaders) {
const folderName = getExtensionFolderName(extension.name);
const referencePageUrl = `${gdevelopWikiUrlRoot}/extensions/${folderName}/reference`;
const helpPageUrl = getHelpLink(extension.helpPath) || referencePageUrl;
const authorNamesWithLinks = generateAuthorNamesWithLinks(
extensionShortHeader.authors || []
);
const referencePageContent =
`# ${extension.fullName}` +
'\n\n' +
generateSvgImageIcon(extension.previewIconUrl) +
'\n' +
`${extension.shortDescription}\n` +
'\n' +
`**Authors and contributors** to this community extension: ${authorNamesWithLinks}.\n` +
'\n' +
'---\n' +
'\n' +
convertMarkdownToDokuWikiMarkdown(extension.description) +
'\n' +
generateExtensionFooterText(extension.fullName);
const extensionReferenceFilePath = path.join(
extensionsRootPath,
folderName,
'reference.txt'
);
await fs.mkdir(path.dirname(extensionReferenceFilePath), {
recursive: true,
});
await fs.writeFile(extensionReferenceFilePath, referencePageContent);
console.info(` File generated: ${extensionReferenceFilePath}`);
indexPageContent +=
'## ' +
extension.fullName +
'\n' +
// Use the `&.png?` syntax to force Dokuwiki to display the image.
// See https://www.dokuwiki.org/images.
generateSvgImageIcon(extension.previewIconUrl) +
'\n' +
extension.shortDescription +
'\n\n' +
// Link to help page or to reference if none.
`[[${helpPageUrl}|Read more...]]` +
(helpPageUrl !== referencePageUrl
? ` ([[${referencePageUrl}|reference]])`
: '') +
'\n\n';
} of reviewedExtensionsAndExtensionShortHeaders) {
await createExtensionReferencePage(extension, extensionShortHeader, false);
}
indexPageContent += generateAllExtensionsSections(reviewedExtensionsAndExtensionShortHeaders);
indexPageContent += `## Community extensions
The following extensions are made by community members — but not reviewed
by the GDevelop extension team. As such, we can't guarantee it
meets all the quality standards of official extensions. In case of
doubt, contact the author to know more about what the extension
does or inspect its content before using it.
`;
for (const {
extension,
extensionShortHeader,
} of communityExtensionsAndExtensionShortHeaders) {
await createExtensionReferencePage(extension, extensionShortHeader, true);
}
indexPageContent += generateAllExtensionsSections(communityExtensionsAndExtensionShortHeaders);
indexPageContent += `
## Make your own extension

View File

@@ -18,7 +18,7 @@ const convertMarkdownToDokuWikiMarkdown = markdownText => {
.replace(/\[(.*?)\]\((.*?)\)/g, (match, linkText, linkUrl) => {
const url = linkUrl.replace(/^\/+/, '');
const title = linkText.replace(/^\[(.*?)\]/, '$1');
return `{{${url}|${title}}}`;
return `[[${url}|${title}]]`;
})
// Add a new line before each list, to make sure DokuWiki renders it correctly.
.replace(/((\n[-\*].*)+)/gm, '\n$1')

View File

@@ -11,6 +11,10 @@ import {
listAllAuthors,
listAllLicenses,
} from '../Utils/GDevelopServices/Asset';
import {
listListedPrivateAssetPacks,
type PrivateAssetPackListingData,
} from '../Utils/GDevelopServices/Shop';
import { useSearchItem, SearchFilter } from '../UI/Search/UseSearchItem';
import {
TagAssetStoreSearchFilter,
@@ -27,8 +31,11 @@ import {
assetStoreHomePageState,
} from './AssetStoreNavigator';
import { type ChosenCategory } from '../UI/Search/FiltersChooser';
import shuffle from 'lodash/shuffle';
const defaultSearchText = '';
// TODO: Remove once the marketplace is up and running.
const ACTIVATE_ASSET_PACK_MARKETPLACE = false;
export type AssetFiltersState = {|
animatedFilter: AnimatedAssetStoreSearchFilter,
@@ -48,6 +55,8 @@ export type AssetFiltersState = {|
type AssetStoreState = {|
filters: ?Filters,
assetPacks: ?AssetPacks,
privateAssetPacks: ?Array<PrivateAssetPackListingData>,
assetPackRandomOrdering: ?Array<number>,
authors: ?Array<Author>,
licenses: ?Array<License>,
environment: Environment,
@@ -71,6 +80,8 @@ type AssetStoreState = {|
export const AssetStoreContext = React.createContext<AssetStoreState>({
filters: null,
assetPacks: null,
privateAssetPacks: null,
assetPackRandomOrdering: null,
authors: null,
licenses: null,
environment: 'live',
@@ -125,6 +136,12 @@ const getAssetShortHeaderSearchTerms = (assetShortHeader: AssetShortHeader) => {
);
};
const getAssetPackRandomOrdering = (length: number): Array<number> => {
const array = new Array(length).fill(0).map((_, index) => index);
return ACTIVATE_ASSET_PACK_MARKETPLACE ? shuffle(array) : array;
};
export const AssetStoreStateProvider = ({
children,
}: AssetStoreStateProviderProps) => {
@@ -133,6 +150,14 @@ export const AssetStoreStateProvider = ({
}>(null);
const [filters, setFilters] = React.useState<?Filters>(null);
const [assetPacks, setAssetPacks] = React.useState<?AssetPacks>(null);
const [
assetPackRandomOrdering,
setAssetPackRandomOrdering,
] = React.useState<?Array<number>>(null);
const [
privateAssetPacks,
setPrivateAssetPacks,
] = React.useState<?Array<PrivateAssetPackListingData>>(null);
const [authors, setAuthors] = React.useState<?Array<Author>>(null);
const [licenses, setLicenses] = React.useState<?Array<License>>(null);
const [environment, setEnvironment] = React.useState<Environment>('live');
@@ -215,6 +240,9 @@ export const AssetStoreStateProvider = ({
} = await listAllAssets({ environment });
const authors = await listAllAuthors({ environment });
const licenses = await listAllLicenses({ environment });
const privateAssetPacks = ACTIVATE_ASSET_PACK_MARKETPLACE
? await listListedPrivateAssetPacks()
: [];
const assetShortHeadersById = {};
assetShortHeaders.forEach(assetShortHeader => {
@@ -229,6 +257,7 @@ export const AssetStoreStateProvider = ({
setAssetPacks(assetPacks);
setAuthors(authors);
setLicenses(licenses);
setPrivateAssetPacks(privateAssetPacks);
} catch (error) {
console.error(
`Unable to load the assets from the asset store:`,
@@ -259,6 +288,25 @@ export const AssetStoreStateProvider = ({
[fetchAssetsAndFilters, assetShortHeadersById, isLoading]
);
// Randomize asset packs when number of asset packs and private asset packs change
const assetPackCount = assetPacks
? assetPacks.starterPacks.length
: undefined;
const privateAssetPackCount = privateAssetPacks
? privateAssetPacks.length
: undefined;
React.useEffect(
() => {
if (assetPackCount === undefined || privateAssetPackCount === undefined) {
return;
}
setAssetPackRandomOrdering(
getAssetPackRandomOrdering(assetPackCount + privateAssetPackCount)
);
},
[assetPackCount, privateAssetPackCount]
);
const currentPage = navigationState.getCurrentPage();
const { chosenCategory, chosenFilters } = currentPage.filtersState;
const searchResults: ?Array<AssetShortHeader> = useSearchItem(
@@ -276,6 +324,8 @@ export const AssetStoreStateProvider = ({
fetchAssetsAndFilters,
filters,
assetPacks,
privateAssetPacks,
assetPackRandomOrdering,
authors,
licenses,
environment,
@@ -319,6 +369,8 @@ export const AssetStoreStateProvider = ({
fetchAssetsAndFilters,
filters,
assetPacks,
privateAssetPacks,
assetPackRandomOrdering,
authors,
licenses,
environment,

View File

@@ -1,16 +1,14 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import GridListTile from '@material-ui/core/GridListTile';
import GridList from '@material-ui/core/GridList';
import Paper from '@material-ui/core/Paper';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import Text from '../UI/Text';
import { type AssetPacks } from '../Utils/GDevelopServices/Asset';
import {
GridListTile,
GridList,
Paper,
makeStyles,
createStyles,
} from '@material-ui/core';
import type { AssetPacks, AssetPack } from '../Utils/GDevelopServices/Asset';
import { type PrivateAssetPackListingData } from '../Utils/GDevelopServices/Shop';
import { shouldValidate } from '../UI/KeyboardShortcuts/InteractionKeys';
import { Line, Column } from '../UI/Grid';
import ScrollView from '../UI/ScrollView';
@@ -57,18 +55,149 @@ const useStylesForGridListItem = makeStyles(theme =>
})
);
const AssetPackTile = ({
assetPack,
onSelect,
style,
}: {
assetPack: AssetPack,
onSelect: (tag: string) => void,
/** Props needed so that GidList component can adjust tile size */
style?: any,
}) => {
const classesForGridListItem = useStylesForGridListItem();
const gdevelopTheme = React.useContext(ThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
key={assetPack.tag}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event)) {
onSelect(assetPack.tag);
}
}}
style={style}
onClick={() => onSelect(assetPack.tag)}
>
<Paper
elevation={2}
style={{
...styles.paper,
backgroundColor: gdevelopTheme.list.itemsBackgroundColor,
}}
>
<CorsAwareImage
key={assetPack.name}
style={styles.previewImage}
src={assetPack.thumbnailUrl}
alt={`Preview image of asset pack ${assetPack.name}`}
/>
<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>
</Paper>
</GridListTile>
);
};
const PrivateAssetPackTile = ({
assetPack,
onSelect,
style,
}: {
assetPack: PrivateAssetPackListingData,
onSelect: (assetPack: PrivateAssetPackListingData) => void,
/** Props needed so that GidList component can adjust tile size */
style?: any,
}) => {
const classesForGridListItem = useStylesForGridListItem();
const gdevelopTheme = React.useContext(ThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
key={assetPack.id}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event)) {
onSelect(assetPack);
}
}}
style={style}
onClick={() => onSelect(assetPack)}
>
<Paper
elevation={2}
style={{
...styles.paper,
backgroundColor: gdevelopTheme.list.itemsBackgroundColor,
}}
>
<CorsAwareImage
key={assetPack.name}
style={styles.previewImage}
src={assetPack.thumbnailUrls[0]}
alt={`Preview image of asset pack ${assetPack.name}`}
/>
<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.description}</Trans>
</Text>
</Line>
</Column>
</Paper>
</GridListTile>
);
};
type Props = {|
assetPacks: AssetPacks,
privateAssetPacks: Array<PrivateAssetPackListingData>,
assetPackRandomOrdering: Array<number>,
onPackSelection: string => void,
onPrivateAssetPackSelection: PrivateAssetPackListingData => void,
|};
export const AssetsHome = ({
assetPacks: { starterPacks },
privateAssetPacks,
assetPackRandomOrdering,
onPackSelection,
onPrivateAssetPackSelection,
}: Props) => {
const classesForGridListItem = useStylesForGridListItem();
const windowWidth = useResponsiveWindowWidth();
const gdevelopTheme = React.useContext(ThemeContext);
const starterPacksTiles = starterPacks.map(pack => (
<AssetPackTile assetPack={pack} onSelect={onPackSelection} />
));
const privateAssetPacksTiles = privateAssetPacks.map(pack => (
<PrivateAssetPackTile
assetPack={pack}
onSelect={onPrivateAssetPackSelection}
/>
));
const allTiles = starterPacksTiles
.concat(privateAssetPacksTiles)
.map((tile, index) => ({ pos: assetPackRandomOrdering[index], tile }))
.sort((a, b) => a.pos - b.pos)
.map(sortObject => sortObject.tile);
return (
<ScrollView>
@@ -78,49 +207,7 @@ export const AssetsHome = ({
cellHeight="auto"
spacing={cellSpacing}
>
{starterPacks.map((pack, index) => (
<GridListTile
classes={classesForGridListItem}
key={pack.tag}
tabIndex={0}
onKeyPress={(
event: SyntheticKeyboardEvent<HTMLLIElement>
): void => {
if (shouldValidate(event)) {
onPackSelection(pack.tag);
}
}}
onClick={() => onPackSelection(pack.tag)}
>
<Paper
elevation={2}
style={{
...styles.paper,
backgroundColor: gdevelopTheme.list.itemsBackgroundColor,
}}
>
<CorsAwareImage
key={pack.name}
style={styles.previewImage}
src={pack.thumbnailUrl}
alt={pack.name}
/>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{pack.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
<Trans>{pack.assetsCount} Assets</Trans>
{pack.userFriendlyPrice
? ' - ' + pack.userFriendlyPrice
: null}
</Text>
</Line>
</Column>
</Paper>
</GridListTile>
))}
{allTiles}
</GridList>
</ScrollView>
);

View File

@@ -23,6 +23,7 @@ import { IconContainer } from '../../UI/IconContainer';
import { UserPublicProfileChip } from '../../UI/User/UserPublicProfileChip';
import Window from '../../Utils/Window';
import { useExtensionUpdate } from './UseExtensionUpdates';
import HelpButton from '../../UI/HelpButton';
const getTransformedDescription = (extensionHeader: ExtensionHeader) => {
if (
@@ -142,17 +143,25 @@ const ExtensionInstallDialog = ({
/>
</LeftLoader>,
]}
secondaryActions={
onEdit
? [
<FlatButton
key="edit-extension"
label={<Trans>Open in editor</Trans>}
onClick={onEdit}
/>,
]
: undefined
}
secondaryActions={[
onEdit ? (
<FlatButton
key="edit-extension"
label={<Trans>Open in editor</Trans>}
onClick={onEdit}
/>
) : (
undefined
),
extensionHeader && extensionHeader.helpPath ? (
<HelpButton
key="help-button"
helpPagePath={extensionHeader.helpPath}
/>
) : (
undefined
),
].filter(Boolean)}
open
cannotBeDismissed={isInstalling}
onRequestClose={onClose}

View File

@@ -28,6 +28,9 @@ type ExtensionStoreState = {|
error: ?Error,
searchText: string,
setSearchText: string => void,
allCategories: string[],
chosenCategory: string,
setChosenCategory: string => void,
extensionShortHeadersByName: { [name: string]: ExtensionShortHeader },
filtersState: FiltersState,
|};
@@ -39,6 +42,10 @@ export const ExtensionStoreContext = React.createContext<ExtensionStoreState>({
error: null,
searchText: '',
setSearchText: () => {},
allCategories: [],
// '' means all categories.
chosenCategory: '',
setChosenCategory: () => {},
extensionShortHeadersByName: {},
filtersState: {
chosenFilters: new Set(),
@@ -67,6 +74,7 @@ export const ExtensionStoreStateProvider = ({
const preferences = React.useContext(PreferencesContext);
const { showCommunityExtensions } = preferences.values;
const [filters, setFilters] = React.useState<?Filters>(null);
const [allCategories, setAllCategories] = React.useState<Array<string>>([]);
const [firstExtensionIds, setFirstExtensionIds] = React.useState<
Array<string>
>([]);
@@ -76,6 +84,7 @@ export const ExtensionStoreStateProvider = ({
const [searchText, setSearchText] = React.useState(
defaultSearchText || emptySearchText
);
const [chosenCategory, setChosenCategory] = React.useState('');
const filtersState = useFilters();
const fetchExtensionsAndFilters = React.useCallback(
@@ -91,13 +100,22 @@ export const ExtensionStoreStateProvider = ({
try {
const extensionRegistry: ExtensionsRegistry = await getExtensionsRegistry();
const { extensionShortHeaders, allTags } = extensionRegistry;
const {
extensionShortHeaders,
allTags,
allCategories,
} = extensionRegistry;
const sortedTags = allTags
.slice()
.sort((tag1, tag2) =>
tag1.toLowerCase().localeCompare(tag2.toLowerCase())
);
const sortedCategories = allCategories
.slice()
.sort((tag1, tag2) =>
tag1.toLowerCase().localeCompare(tag2.toLowerCase())
);
const extensionShortHeadersByName = {};
extensionShortHeaders.forEach(extension => {
@@ -115,6 +133,7 @@ export const ExtensionStoreStateProvider = ({
defaultTags: sortedTags,
tagsTree: [],
});
setAllCategories(sortedCategories);
setFirstExtensionIds(
extensionRegistry.views
? extensionRegistry.views.default.firstExtensionIds
@@ -150,14 +169,14 @@ export const ExtensionStoreStateProvider = ({
[fetchExtensionsAndFilters, extensionShortHeadersByName, isLoading]
);
const { chosenCategory, chosenFilters } = filtersState;
const searchResults: ?Array<{|
item: ExtensionShortHeader,
matches: SearchMatch[],
|}> = useSearchStructuredItem(extensionShortHeadersByName, {
searchText,
chosenCategory,
chosenFilters,
chosenItemCategory: chosenCategory,
chosenCategory: filtersState.chosenCategory,
chosenFilters: filtersState.chosenFilters,
excludedTiers: showCommunityExtensions
? noExcludedTiers
: excludedCommunityTiers,
@@ -169,6 +188,9 @@ export const ExtensionStoreStateProvider = ({
searchResults,
fetchExtensionsAndFilters,
filters,
allCategories,
chosenCategory,
setChosenCategory,
error,
searchText,
setSearchText,
@@ -179,6 +201,9 @@ export const ExtensionStoreStateProvider = ({
searchResults,
error,
filters,
allCategories,
chosenCategory,
setChosenCategory,
searchText,
extensionShortHeadersByName,
filtersState,

View File

@@ -17,7 +17,11 @@ import {
import useDismissableTutorialMessage from '../../Hints/useDismissableTutorialMessage';
import { t } from '@lingui/macro';
import { ColumnStackLayout } from '../../UI/Layout';
import { Column } from '../../UI/Grid';
import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext';
import { ResponsiveLineStackLayout } from '../../UI/Layout';
import SearchBarSelectField from '../../UI/SearchBarSelectField';
import SelectOption from '../../UI/SelectOption';
type Props = {|
isInstalling: boolean,
@@ -48,6 +52,9 @@ export const ExtensionStore = ({
filtersState,
searchText,
setSearchText,
allCategories,
chosenCategory,
setChosenCategory,
} = React.useContext(ExtensionStoreContext);
React.useEffect(
@@ -94,14 +101,33 @@ export const ExtensionStore = ({
{windowWidth => (
<ColumnStackLayout expand noMargin useFullHeight>
<ColumnStackLayout>
<SearchBar
value={searchText}
onChange={setSearchText}
onRequestSearch={() => {}}
tagsHandler={tagsHandler}
tags={filters && filters.allTags}
placeholder={t`Search extensions`}
/>
<ResponsiveLineStackLayout noMargin>
<SearchBarSelectField
value={chosenCategory}
onChange={(e, i, value: string) => {
setChosenCategory(value);
}}
>
<SelectOption value="" primaryText={t`All categories`} />
{allCategories.map(category => (
<SelectOption
key={category}
value={category}
primaryText={category}
/>
))}
</SearchBarSelectField>
<Column expand noMargin>
<SearchBar
value={searchText}
onChange={setSearchText}
onRequestSearch={() => {}}
tagsHandler={tagsHandler}
tags={filters && filters.allTags}
placeholder={t`Search extensions`}
/>
</Column>
</ResponsiveLineStackLayout>
<Toggle
onToggle={(e, check) =>
preferences.setShowCommunityExtensions(check)

View File

@@ -462,6 +462,7 @@ describe('InstallAsset', () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
allCategories: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
@@ -481,6 +482,7 @@ describe('InstallAsset', () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
allCategories: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
@@ -627,6 +629,7 @@ describe('InstallAsset', () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
allCategories: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
@@ -664,6 +667,7 @@ describe('InstallAsset', () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
allCategories: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,

View File

@@ -0,0 +1,246 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { type PrivateAssetPackListingData } from '../Utils/GDevelopServices/Shop';
import {
getPrivateAssetPackDetails,
type PrivateAssetPackDetails,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { t, Trans } from '@lingui/macro';
import Dialog from '../UI/Dialog';
import TextButton from '../UI/TextButton';
import AlertMessage from '../UI/AlertMessage';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import { Column, LargeSpacer, Line, Spacer } from '../UI/Grid';
import {
getUserPublicProfile,
type UserPublicProfile,
} from '../Utils/GDevelopServices/User';
import PublicProfileDialog from '../Profile/PublicProfileDialog';
import Link from '../UI/Link';
import Mark from '../UI/CustomSvgIcons/Mark';
import Cross from '../UI/CustomSvgIcons/Cross';
import { Paper } from '@material-ui/core';
import ResponsiveImagesGallery from '../UI/ResponsiveImagesGallery';
import { useResponsiveWindowWidth } from '../UI/Reponsive/ResponsiveWindowMeasurer';
type Props = {|
privateAssetPack: PrivateAssetPackListingData,
onClose: () => void,
|};
const sortedContentType = [
'sprite',
'9patch',
'tiled',
'particleEmitter',
'font',
'audio',
'partial',
];
const contentTypeToMessageDescriptor = {
sprite: t`Sprites`,
'9patch': t`Panel sprites`,
tiled: t`Tiled sprites`,
particleEmitter: t`Particle emitters`,
font: t`Fonts`,
audio: t`Audios`,
partial: t`Other`,
};
const PrivateAssetPackDialog = ({
privateAssetPack: { id, name, description, sellerId },
onClose,
}: Props) => {
const [
assetPackDetails,
setAssetPackDetails,
] = React.useState<?PrivateAssetPackDetails>(null);
const [isFetchingDetails, setIsFetchingDetails] = React.useState<boolean>(
false
);
const [
openSellerPublicProfileDialog,
setOpenSellerPublicProfileDialog,
] = React.useState<boolean>(false);
const [
sellerPublicProfile,
setSellerPublicProfile,
] = React.useState<?UserPublicProfile>(null);
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const windowWidth = useResponsiveWindowWidth();
React.useEffect(
() => {
(async () => {
setIsFetchingDetails(true);
try {
const details = await getPrivateAssetPackDetails(id);
const profile = await getUserPublicProfile(sellerId);
setAssetPackDetails(details);
setSellerPublicProfile(profile);
} catch (error) {
if (error.response && error.response.status === 404) {
setErrorText(
<Trans>
Asset pack not found - An error occurred, please try again
later.
</Trans>
);
} else {
setErrorText(
<Trans>An error occurred, please try again later.</Trans>
);
}
} finally {
setIsFetchingDetails(false);
}
})();
},
[id, sellerId]
);
return (
<I18n>
{({ i18n }) => (
<>
<Dialog
maxWidth="lg"
open
onRequestClose={onClose}
actions={[
<TextButton
key="cancel"
label={<Trans>Cancel</Trans>}
onClick={onClose}
/>,
]}
onApply={() => {}}
flexColumnBody
fullHeight
>
{errorText ? (
<AlertMessage kind="error">
<Text>{errorText}</Text>
</AlertMessage>
) : isFetchingDetails ? (
<>
<Text size="title">{name}</Text>
<Column expand>
<PlaceholderLoader />
</Column>
</>
) : assetPackDetails && sellerPublicProfile ? (
<>
<Column noMargin>
<Text size="title">{name}</Text>
<Text size="body2">
<Trans>by</Trans>{' '}
<Link
onClick={() => setOpenSellerPublicProfileDialog(true)}
href="#"
>
{sellerPublicProfile.username || ''}
</Link>
</Text>
</Column>
<ResponsiveLineStackLayout noColumnMargin noMargin>
<Column
useFullHeight
expand={windowWidth !== 'small'}
noMargin
noOverflowParent
>
<ResponsiveImagesGallery
imagesUrls={assetPackDetails.previewImageUrls}
altTextTemplate={`Asset pack ${name} preview image {imageIndex}`}
horizontalOuterMarginToEatOnMobile={8}
/>
</Column>
<Column useFullHeight expand noMargin>
<Paper
variant="outlined"
style={{ padding: windowWidth === 'small' ? 20 : 30 }}
>
<Column noMargin>
<LargeSpacer /> {/* To be replaced by prices */}
<Text noMargin>{assetPackDetails.longDescription}</Text>
<LargeSpacer />
<ResponsiveLineStackLayout noMargin noColumnMargin>
<Column noMargin expand>
<Text size="sub-title">
<Trans>Content</Trans>
</Text>
{sortedContentType.map(type => {
if (assetPackDetails.content[type]) {
return (
<li key={type}>
<Text displayInlineAsSpan noMargin>
{assetPackDetails.content[type]}{' '}
{i18n._(
contentTypeToMessageDescriptor[type]
)}
</Text>
</li>
);
}
return null;
})}
</Column>
<Column noMargin expand>
<Text size="sub-title">
<Trans>Licensing</Trans>
</Text>
<Line noMargin alignItems="center">
<Mark fontSize="small" />
<Spacer />
<Text displayInlineAsSpan noMargin>
<Trans>Personal projects</Trans>
</Text>
</Line>
<Line noMargin alignItems="center">
<Mark fontSize="small" />
<Spacer />
<Text displayInlineAsSpan noMargin>
<Trans>Professional projects</Trans>
</Text>
</Line>
<Line noMargin alignItems="center">
<Mark fontSize="small" />
<Spacer />
<Text displayInlineAsSpan noMargin>
<Trans>Asset modification</Trans>
</Text>
</Line>
<Line noMargin alignItems="center">
<Cross fontSize="small" />
<Spacer />
<Text displayInlineAsSpan noMargin>
<Trans>Redistribution &amp; reselling</Trans>
</Text>
</Line>
</Column>
</ResponsiveLineStackLayout>
</Column>
</Paper>
</Column>
</ResponsiveLineStackLayout>
</>
) : null}
</Dialog>
{openSellerPublicProfileDialog && (
<PublicProfileDialog
userId={sellerId}
onClose={() => setOpenSellerPublicProfileDialog(false)}
/>
)}
</>
)}
</I18n>
);
};
export default PrivateAssetPackDialog;

View File

@@ -14,6 +14,7 @@ import {
sendAssetPackOpened,
} from '../Utils/Analytics/EventSender';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import { type PrivateAssetPackListingData } from '../Utils/GDevelopServices/Shop';
import { BoxSearchResults } from '../UI/Search/BoxSearchResults';
import { type SearchBarInterface } from '../UI/SearchBar';
import {
@@ -32,6 +33,7 @@ import IconButton from '../UI/IconButton';
import { AssetDetails } from './AssetDetails';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import Home from '@material-ui/icons/Home';
import PrivateAssetPackDialog from './PrivateAssetPackDialog';
type Props = {|
project: gdProject,
@@ -40,6 +42,7 @@ type Props = {|
export const AssetStore = ({ project }: Props) => {
const {
assetPacks,
privateAssetPacks,
searchResults,
error,
fetchAssetsAndFilters,
@@ -47,6 +50,7 @@ export const AssetStore = ({ project }: Props) => {
searchText,
setSearchText,
assetFiltersState,
assetPackRandomOrdering,
} = React.useContext(AssetStoreContext);
const {
isOnHomePage,
@@ -57,6 +61,10 @@ export const AssetStore = ({ project }: Props) => {
const searchBar = React.useRef<?SearchBarInterface>(null);
const shouldAutofocusSearchbar = useShouldAutofocusSearchbar();
const [isFiltersPanelOpen, setIsFiltersPanelOpen] = React.useState(false);
const [
selectedPrivateAssetPack,
setSelectedPrivateAssetPack,
] = React.useState<?PrivateAssetPackListingData>(null);
const onOpenDetails = (assetShortHeader: AssetShortHeader) => {
sendAssetOpened({
@@ -257,13 +265,21 @@ export const AssetStore = ({ project }: Props) => {
)}
</Background>
)}
{isOnHomePage && !assetPacks && <PlaceholderLoader />}
{isOnHomePage && assetPacks && (
<AssetsHome
assetPacks={assetPacks}
onPackSelection={selectPack}
/>
{isOnHomePage && !(assetPacks && privateAssetPacks) && (
<PlaceholderLoader />
)}
{isOnHomePage &&
assetPacks &&
privateAssetPacks &&
assetPackRandomOrdering && (
<AssetsHome
assetPacks={assetPacks}
privateAssetPacks={privateAssetPacks}
assetPackRandomOrdering={assetPackRandomOrdering}
onPackSelection={selectPack}
onPrivateAssetPackSelection={setSelectedPrivateAssetPack}
/>
)}
{!isOnHomePage && !openedAssetShortHeader && (
<BoxSearchResults
baseSize={128}
@@ -292,6 +308,12 @@ export const AssetStore = ({ project }: Props) => {
onOpenDetails={onOpenDetails}
/>
)}
{selectedPrivateAssetPack && (
<PrivateAssetPackDialog
privateAssetPack={selectedPrivateAssetPack}
onClose={() => setSelectedPrivateAssetPack(null)}
/>
)}
</Line>
</Column>
</>

View File

@@ -53,6 +53,7 @@ export type CommandName =
| 'ADD_SUBEVENT'
| 'ADD_COMMENT_EVENT'
| 'TOGGLE_EVENT_DISABLED'
| 'TOGGLE_CONDITION_INVERTED'
| 'CHOOSE_AND_ADD_EVENT'
| 'EVENTS_EDITOR_UNDO'
| 'EVENTS_EDITOR_REDO'
@@ -284,6 +285,10 @@ const commandsList: { [CommandName]: CommandMetadata } = {
area: 'EVENTS',
displayText: t`Toggle disabled event`,
},
TOGGLE_CONDITION_INVERTED: {
area: 'EVENTS',
displayText: t`Toggle inverted condition`,
},
CHOOSE_AND_ADD_EVENT: {
area: 'EVENTS',
displayText: t`Choose and add an event...`,

View File

@@ -206,6 +206,11 @@ export default class EventsBasedBehaviorPropertiesEditor extends React.Component
}}
checkedIcon={<Visibility />}
uncheckedIcon={<VisibilityOff />}
disabled={
property.getType() === 'Behavior' &&
// Allow to make it visible just in case.
!property.isHidden()
}
/>
<ElementWithMenu
element={
@@ -241,6 +246,9 @@ export default class EventsBasedBehaviorPropertiesEditor extends React.Component
value={property.getType()}
onChange={(e, i, value: string) => {
property.setType(value);
if (value === 'Behavior') {
property.setHidden(false);
}
this.forceUpdate();
this.props.onPropertiesUpdated();
}}

View File

@@ -1,6 +1,7 @@
// @flow
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import * as React from 'react';
import TextField from '../UI/TextField';
@@ -12,6 +13,9 @@ import EventBasedObjectChildrenEditor from './EventBasedObjectChildrenEditor';
import { ColumnStackLayout } from '../UI/Layout';
import { Line } from '../UI/Grid';
import { type OnFetchNewlyAddedResourcesFunction } from '../ProjectsStorage/ResourceFetcher';
import { showWarningBox } from '../UI/Messages/MessageBox';
const gd: libGDevelop = global.gd;
type TabName = 'configuration' | 'properties' | 'children';
@@ -105,6 +109,34 @@ export default class EventsBasedObjectEditor extends React.Component<
fullWidth
rows={3}
/>
<I18n>
{({ i18n }) => (
<SemiControlledTextField
commitOnBlur
floatingLabelText={
<Trans>Default name for created objects</Trans>
}
value={
eventsBasedObject.getDefaultName() ||
eventsBasedObject.getName()
}
onChange={text => {
if (gd.Project.validateName(text)) {
eventsBasedObject.setDefaultName(text);
this.forceUpdate();
} else {
showWarningBox(
i18n._(
t`This name is invalid. Only use alphanumeric characters (0-9, a-z) and underscores. Digits are not allowed as the first character.`
),
{ delayToNextTick: true }
);
}
}}
fullWidth
/>
)}
</I18n>
{eventsBasedObject
.getEventsFunctions()
.getEventsFunctionsCount() === 0 && (

View File

@@ -208,6 +208,10 @@ export const ExtensionOptionsEditor = ({
text: 'Ads',
value: 'Ads',
},
{
text: 'Visual effect',
value: 'Visual effect',
},
{
text: 'Audio',
value: 'Audio',
@@ -216,6 +220,10 @@ export const ExtensionOptionsEditor = ({
text: 'Advanced',
value: 'Advanced',
},
{
text: 'Camera',
value: 'Camera',
},
{
text: 'Device',
value: 'Device',
@@ -224,6 +232,14 @@ export const ExtensionOptionsEditor = ({
text: 'Input',
value: 'Input',
},
{
text: 'Game mechanic',
value: 'Game mechanic',
},
{
text: 'Movement',
value: 'Movement',
},
{
text: 'Network',
value: 'Network',
@@ -232,6 +248,10 @@ export const ExtensionOptionsEditor = ({
text: 'Third-party',
value: 'Third-party',
},
{
text: 'User interface',
value: 'User interface',
},
]}
/>
<SemiControlledTextField

View File

@@ -310,6 +310,7 @@ const generateFreeFunction = (
});
includeFiles.delete();
eventsFunctionsExtensionCodeGenerator.delete();
return options.eventsFunctionCodeWriter
.writeFunctionCode(functionName, code)

View File

@@ -72,6 +72,7 @@ const getSearchInitialOffset = (
selectedEventPosition,
...searchResultsPositions
] = positionFinder.getPositions().toJSArray();
positionFinder.delete();
// Search results are considered to be sorted by position
// (top to bottom in the flatten event tree)

View File

@@ -188,6 +188,7 @@ const Instruction = (props: Props) => {
);
expressionNode.visit(expressionValidator);
expressionIsValid = expressionValidator.getErrors().size() === 0;
expressionValidator.delete();
} else if (gd.ParameterMetadata.isObject(parameterType)) {
const objectOrGroupName = instruction
.getParameter(parameterIndex)

View File

@@ -631,7 +631,7 @@ export default class ThemableEventsTree extends Component<
[icon]: true,
})}
alt=""
src={getThumbnail(project, object)}
src={getThumbnail(project, object.getConfiguration())}
/>
);
};

View File

@@ -276,9 +276,11 @@ const InstructionParametersEditor = React.forwardRef<
: undefined,
}}
/>
<Text style={styles.description}>
{instructionMetadata.getDescription()}
</Text>
<Column expand>
<Text style={styles.description}>
{instructionMetadata.getDescription()}
</Text>
</Column>
{isAnEventFunctionMetadata(instructionMetadata) && (
<IconButton
onClick={() => {

View File

@@ -48,7 +48,7 @@ export const renderObjectListItem = ({
iconSize={iconSize}
src={ObjectsRenderingService.getThumbnail(
project,
objectWithContext.object
objectWithContext.object.getConfiguration()
)}
/>
}

View File

@@ -157,7 +157,7 @@ const DisplayedObjectAutocompletion = React.forwardRef(
project && expressionAutocompletion.object
? ObjectsRenderingService.getThumbnail(
project,
expressionAutocompletion.object
expressionAutocompletion.object.getConfiguration()
)
: 'res/types/object.png';

View File

@@ -6,6 +6,7 @@ import ExpressionParametersEditor from './ExpressionParametersEditor';
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
import Text from '../../../UI/Text';
import { Column } from '../../../UI/Grid';
import HelpButton from '../../../UI/HelpButton';
export type ParameterValues = Array<string>;
@@ -59,6 +60,16 @@ const ExpressionParametersEditorDialog = ({
onClick={() => onDone(parameterValues)}
/>,
]}
secondaryActions={
expressionMetadata.getHelpPath()
? [
<HelpButton
key="help-button"
helpPagePath={expressionMetadata.getHelpPath()}
/>,
]
: []
}
noMargin
onRequestClose={onRequestClose}
onApply={() => onDone(parameterValues)}

View File

@@ -309,6 +309,8 @@ export default class ExpressionField extends React.Component<Props, State> {
shouldConvertToString,
});
parser.delete();
// Generate the expression with the function call
const newValue =
value.substr(0, cursorPosition) +

View File

@@ -15,8 +15,10 @@ type Props = {|
onAddCommentEvent: () => void,
allEventsMetadata: Array<EventMetadata>,
onAddEvent: (eventType: string) => Array<gdBaseEvent>,
onToggleInvertedCondition: () => void,
onToggleDisabledEvent: () => void,
canToggleEventDisabled: boolean,
canToggleInstructionInverted: boolean,
onRemove: () => void,
canRemove: boolean,
undo: () => void,

View File

@@ -13,8 +13,10 @@ type Props = {|
onAddCommentEvent: () => void,
allEventsMetadata: Array<EventMetadata>,
onAddEvent: (eventType: string) => Array<gdBaseEvent>,
onToggleInvertedCondition: () => void,
onToggleDisabledEvent: () => void,
canToggleEventDisabled: boolean,
canToggleInstructionInverted: boolean,
onRemove: () => void,
canRemove: boolean,
undo: () => void,
@@ -44,6 +46,10 @@ const ToolbarCommands = (props: Props) => {
handler: props.onToggleDisabledEvent,
});
useCommand('TOGGLE_CONDITION_INVERTED', props.canToggleInstructionInverted, {
handler: props.onToggleInvertedCondition,
});
useCommandWithOptions('CHOOSE_AND_ADD_EVENT', true, {
generateOptions: React.useCallback(
() =>

View File

@@ -319,9 +319,16 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
onAddStandardEvent={this._addStandardEvent}
onAddSubEvent={this.addSubEvents}
canAddSubEvent={hasEventSelected(this.state.selection)}
canToggleEventDisabled={hasEventSelected(this.state.selection)}
canToggleEventDisabled={
hasEventSelected(this.state.selection) &&
this._selectionCanToggleDisabled()
}
canToggleInstructionInverted={hasInstructionSelected(
this.state.selection
)}
onAddCommentEvent={this._addCommentEvent}
onAddEvent={this.addNewEvent}
onToggleInvertedCondition={this._invertSelectedConditions}
onToggleDisabledEvent={this.toggleDisabled}
canRemove={hasSomethingSelected(this.state.selection)}
onRemove={this.deleteSelection}
@@ -544,6 +551,11 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
label: i18n._(t`Invert Condition`),
click: () => this._invertSelectedConditions(),
visible: hasSelectedAtLeastOneCondition(this.state.selection),
accelerator: getShortcutDisplayName(
this.props.preferences.values.userShortcutMap[
'TOGGLE_CONDITION_INVERTED'
] || 'KeyJ'
),
},
{
label: i18n._(t`Toggle Wait the Action to End`),
@@ -1012,6 +1024,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
const positions = this._getChangedEventRows(eventsWithDeletion);
eventsRemover.launch(events);
eventsRemover.delete();
// /!\ Events were changed, so any reference to an existing event can now
// be invalid. Make sure to immediately trigger a forced update before

View File

@@ -13,6 +13,7 @@ import {
getExistingTargetIdForDebuggerId,
registerPreviewWindow,
} from './BrowserPreviewDebuggerServer';
import Window from '../../../Utils/Window';
const gd: libGDevelop = global.gd;
type State = {|
@@ -124,6 +125,7 @@ export default class BrowserS3PreviewLauncher extends React.Component<
outputDir
);
previewExportOptions.setLayoutName(layout.getName());
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
if (externalLayout) {
previewExportOptions.setExternalLayoutName(externalLayout.getName());
}

View File

@@ -14,6 +14,7 @@ import {
getDebuggerServerAddress,
localPreviewDebuggerServer,
} from './LocalPreviewDebuggerServer';
import Window from '../../../Utils/Window';
const electron = optionalRequire('electron');
const path = optionalRequire('path');
const ipcRenderer = electron ? electron.ipcRenderer : null;
@@ -189,6 +190,7 @@ export default class LocalPreviewLauncher extends React.Component<
project,
outputDir
);
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
previewExportOptions.setLayoutName(layout.getName());
if (externalLayout) {
previewExportOptions.setExternalLayoutName(

View File

@@ -1,8 +1,24 @@
// @flow
import transformRect from '../Utils/TransformRect';
import * as PIXI from 'pixi.js-legacy';
import { type InstanceMeasurer } from './InstancesRenderer';
import Rectangle from '../Utils/Rectangle';
export default class InstancesSelection {
constructor({ instanceMeasurer, toCanvasCoordinates }) {
instanceMeasurer: InstanceMeasurer;
toCanvasCoordinates: (x: number, y: number) => [number, number];
highlightedInstance: gdInitialInstance | null;
highlightRectangle: PIXI.Container;
tooltipBackground: PIXI.Container;
tooltipText: PIXI.Container;
constructor({
instanceMeasurer,
toCanvasCoordinates,
}: {
instanceMeasurer: InstanceMeasurer,
toCanvasCoordinates: (x: number, y: number) => [number, number],
}) {
this.instanceMeasurer = instanceMeasurer;
this.toCanvasCoordinates = toCanvasCoordinates;
@@ -20,27 +36,31 @@ export default class InstancesSelection {
this.highlightRectangle.addChild(this.tooltipText);
}
setInstance(instance) {
setInstance(instance: gdInitialInstance | null) {
this.highlightedInstance = instance;
}
getInstance() {
getInstance(): ?gdInitialInstance {
return this.highlightedInstance;
}
getPixiObject(instance) {
getPixiObject(): PIXI.Container {
return this.highlightRectangle;
}
render() {
if (this.highlightedInstance === null) {
const { highlightedInstance } = this;
if (highlightedInstance === null) {
this.highlightRectangle.visible = false;
return;
}
const highlightRectangle = transformRect(
this.toCanvasCoordinates,
this.instanceMeasurer.getInstanceRect(this.highlightedInstance)
this.instanceMeasurer.getInstanceAABB(
highlightedInstance,
new Rectangle()
)
);
this.highlightRectangle.visible = true;
@@ -50,35 +70,35 @@ export default class InstancesSelection {
this.highlightRectangle.alpha = 0.8;
this.highlightRectangle.lineStyle(1, 0x000000, 1);
this.highlightRectangle.drawRect(
highlightRectangle.x,
highlightRectangle.y,
highlightRectangle.width,
highlightRectangle.height
highlightRectangle.left,
highlightRectangle.top,
highlightRectangle.width(),
highlightRectangle.height()
);
this.highlightRectangle.endFill();
const tooltipInfo =
this.highlightedInstance.getObjectName() +
highlightedInstance.getObjectName() +
'\n' +
'X: ' +
parseInt(this.highlightedInstance.getX()) +
parseInt(highlightedInstance.getX()) +
' Y: ' +
parseInt(this.highlightedInstance.getY()) +
parseInt(highlightedInstance.getY()) +
'\n' +
'Layer: ' +
this.highlightedInstance.getLayer() +
highlightedInstance.getLayer() +
' Z: ' +
this.highlightedInstance.getZOrder() +
highlightedInstance.getZOrder() +
'\n';
this.tooltipText.text = tooltipInfo;
this.tooltipText.x = Math.round(
highlightRectangle.x -
highlightRectangle.left -
this.tooltipText.width / 2 +
highlightRectangle.width / 2
highlightRectangle.width() / 2
);
this.tooltipText.y = Math.round(
highlightRectangle.y - this.tooltipText.height
highlightRectangle.top - this.tooltipText.height
);
const padding = 5;

View File

@@ -2,9 +2,10 @@
import { roundPosition } from '../Utils/GridHelpers';
import Rectangle from '../Utils/Rectangle';
import { type InstancesEditorSettings } from './InstancesEditorSettings';
import { type InstanceMeasurer } from './InstancesRenderer';
export default class InstancesMover {
instanceMeasurer: any;
instanceMeasurer: InstanceMeasurer;
instancesEditorSettings: InstancesEditorSettings;
instancePositions: { [number]: { x: number, y: number } };
totalDeltaX: number;
@@ -18,7 +19,7 @@ export default class InstancesMover {
instanceMeasurer,
instancesEditorSettings,
}: {
instanceMeasurer: any,
instanceMeasurer: InstanceMeasurer,
instancesEditorSettings: InstancesEditorSettings,
}) {
this.instanceMeasurer = instanceMeasurer;

View File

@@ -181,11 +181,7 @@ export default class LayerRenderer {
const top = this.getUnrotatedInstanceTop(instance);
const right = left + this.getUnrotatedInstanceWidth(instance);
const bottom = top + this.getUnrotatedInstanceHeight(instance);
bounds.left = left;
bounds.right = right;
bounds.top = top;
bounds.bottom = bottom;
bounds.set({ left, top, right, bottom });
return bounds;
}
@@ -234,10 +230,7 @@ export default class LayerRenderer {
top = Math.min(top, rotatedRectangle[i][1]);
bottom = Math.max(bottom, rotatedRectangle[i][1]);
}
bounds.left = left;
bounds.right = right;
bounds.top = top;
bounds.bottom = bottom;
bounds.set({ left, top, right, bottom });
return bounds;
}

View File

@@ -4,6 +4,11 @@ import ViewPosition from '../ViewPosition';
import * as PIXI from 'pixi.js-legacy';
import Rectangle from '../../Utils/Rectangle';
export type InstanceMeasurer = {|
getInstanceAABB: (gdInitialInstance, Rectangle) => Rectangle,
getUnrotatedInstanceAABB: (gdInitialInstance, Rectangle) => Rectangle,
|};
export default class InstancesRenderer {
project: gdProject;
instances: gdInitialInstancesContainer;
@@ -28,7 +33,7 @@ export default class InstancesRenderer {
pixiContainer: PIXI.Container;
temporaryRectangle: Rectangle;
instanceMeasurer: any;
instanceMeasurer: InstanceMeasurer;
constructor({
project,
@@ -108,19 +113,6 @@ export default class InstancesRenderer {
return layerRenderer.getUnrotatedInstanceAABB(instance, bounds);
},
//TODO Replace by getInstanceAABB (make TransformRect uses Rectangle)
getInstanceRect: instance => {
const aabb = this.instanceMeasurer.getInstanceAABB(
instance,
this.temporaryRectangle
);
return {
x: aabb.left,
y: aabb.top,
width: aabb.width(),
height: aabb.height(),
};
},
};
}

View File

@@ -2,6 +2,7 @@
import Rectangle from '../Utils/Rectangle';
import { roundPositionForResizing } from '../Utils/GridHelpers';
import { type InstancesEditorSettings } from './InstancesEditorSettings';
import { type InstanceMeasurer } from './InstancesRenderer';
export type ResizeGrabbingLocation =
| 'TopLeft'
@@ -49,7 +50,7 @@ const areAnyInstancesNotStraight = (instances: gdInitialInstance[]) => {
};
export default class InstancesResizer {
instanceMeasurer: any;
instanceMeasurer: InstanceMeasurer;
instancesEditorSettings: InstancesEditorSettings;
// The initial state of instances before a resize:
@@ -71,7 +72,7 @@ export default class InstancesResizer {
instanceMeasurer,
instancesEditorSettings,
}: {
instanceMeasurer: any,
instanceMeasurer: InstanceMeasurer,
instancesEditorSettings: InstancesEditorSettings,
}) {
this.instanceMeasurer = instanceMeasurer;

View File

@@ -1,8 +1,9 @@
// @flow
import Rectangle from '../Utils/Rectangle';
import { type InstanceMeasurer } from './InstancesRenderer';
export default class InstancesRotator {
_instanceMeasurer: any;
_instanceMeasurer: InstanceMeasurer;
// Initial state of the instances
// from which the rotation is calculated.
@@ -24,7 +25,7 @@ export default class InstancesRotator {
*/
_fixedPoint: [number, number] = [0, 0];
constructor(instanceMeasurer: any) {
constructor(instanceMeasurer: InstanceMeasurer) {
this._instanceMeasurer = instanceMeasurer;
}

View File

@@ -11,10 +11,12 @@ import {
canMoveOnX,
canMoveOnY,
} from './InstancesResizer';
import { type InstanceMeasurer } from './InstancesRenderer';
import Rectangle from '../Utils/Rectangle';
type Props = {|
instancesSelection: InstancesSelection,
instanceMeasurer: Object, // To be typed in InstancesRenderer
instanceMeasurer: InstanceMeasurer,
onResize: (
deltaX: number,
deltaY: number,
@@ -64,7 +66,7 @@ const resizeGrabbingIconNames = {
*/
export default class SelectedInstances {
instancesSelection: InstancesSelection;
instanceMeasurer: Object; // To be typed in InstancesRenderer
instanceMeasurer: InstanceMeasurer;
onResize: (
deltaX: number,
deltaY: number,
@@ -212,7 +214,10 @@ export default class SelectedInstances {
}
const instance = selection[i];
const instanceRect = this.instanceMeasurer.getInstanceRect(instance);
const instanceRect = this.instanceMeasurer.getInstanceAABB(
instance,
new Rectangle()
);
const selectionRectangle = transformRect(
this.toCanvasCoordinates,
instanceRect
@@ -225,10 +230,10 @@ export default class SelectedInstances {
this.selectedRectangles[i].fill.alpha = 0.3;
this.selectedRectangles[i].alpha = 0.8;
this.selectedRectangles[i].drawRect(
selectionRectangle.x,
selectionRectangle.y,
selectionRectangle.width,
selectionRectangle.height
selectionRectangle.left,
selectionRectangle.top,
selectionRectangle.width(),
selectionRectangle.height()
);
this.selectedRectangles[i].endFill();
@@ -236,18 +241,18 @@ export default class SelectedInstances {
continue;
}
if (!initialised) {
x1 = instanceRect.x;
y1 = instanceRect.y;
x2 = instanceRect.x + instanceRect.width;
y2 = instanceRect.y + instanceRect.height;
x1 = instanceRect.left;
y1 = instanceRect.top;
x2 = instanceRect.left + instanceRect.width();
y2 = instanceRect.top + instanceRect.height();
initialised = true;
} else {
if (instanceRect.x < x1) x1 = instanceRect.x;
if (instanceRect.y < y1) y1 = instanceRect.y;
if (instanceRect.x + instanceRect.width > x2)
x2 = instanceRect.x + instanceRect.width;
if (instanceRect.y + instanceRect.height > y2)
y2 = instanceRect.y + instanceRect.height;
if (instanceRect.left < x1) x1 = instanceRect.left;
if (instanceRect.top < y1) y1 = instanceRect.top;
if (instanceRect.left + instanceRect.width() > x2)
x2 = instanceRect.left + instanceRect.width();
if (instanceRect.top + instanceRect.height() > y2)
y2 = instanceRect.top + instanceRect.height();
}
}

View File

@@ -1,16 +1,17 @@
// @flow
import * as PIXI from 'pixi.js-legacy';
import Rectangle from '../Utils/Rectangle';
import { type InstanceMeasurer } from './InstancesRenderer';
const gd: libGDevelop = global.gd;
export default class SelectionRectangle {
instances: gdInitialInstancesContainer;
instanceMeasurer: any;
instanceMeasurer: InstanceMeasurer;
toSceneCoordinates: (x: number, y: number) => [number, number];
pixiRectangle: PIXI.Graphics;
selectionRectangleStart: any;
selectionRectangleEnd: any;
selectionRectangleStart: { x: number, y: number } | null;
selectionRectangleEnd: { x: number, y: number } | null;
_instancesInSelectionRectangle: gdInitialInstance[];
selector: gdInitialInstanceJSFunctor;
@@ -25,7 +26,7 @@ export default class SelectionRectangle {
toSceneCoordinates,
}: {
instances: gdInitialInstancesContainer,
instanceMeasurer: any,
instanceMeasurer: InstanceMeasurer,
toSceneCoordinates: (x: number, y: number) => [number, number],
}) {
this.instances = instances;
@@ -49,15 +50,16 @@ export default class SelectionRectangle {
this._temporaryAABB
);
if (!this.selectionRectangleStart || !this.selectionRectangleEnd) return;
const { selectionRectangleEnd, selectionRectangleStart } = this;
if (!selectionRectangleStart || !selectionRectangleEnd) return;
const selectionSceneStart = toSceneCoordinates(
this.selectionRectangleStart.x,
this.selectionRectangleStart.y
selectionRectangleStart.x,
selectionRectangleStart.y
);
const selectionSceneEnd = toSceneCoordinates(
this.selectionRectangleEnd.x,
this.selectionRectangleEnd.y
selectionRectangleEnd.x,
selectionRectangleEnd.y
);
if (
@@ -88,7 +90,7 @@ export default class SelectionRectangle {
};
endSelectionRectangle = () => {
if (!this.selectionRectangleStart) return [];
if (!this.selectionRectangleStart || !this.selectionRectangleEnd) return [];
this._instancesInSelectionRectangle.length = 0;
if (this.selectionRectangleStart.x > this.selectionRectangleEnd.x) {
@@ -116,7 +118,7 @@ export default class SelectionRectangle {
}
render() {
if (!this.selectionRectangleStart) {
if (!this.selectionRectangleStart || !this.selectionRectangleEnd) {
this.pixiRectangle.visible = false;
return;
}

View File

@@ -1,5 +1,6 @@
// @flow
import * as PIXI from 'pixi.js-legacy';
import Rectangle from '../Utils/Rectangle';
import { type InstancesEditorSettings } from './InstancesEditorSettings';
type Props = {|
@@ -103,9 +104,17 @@ export default class ViewPosition {
this.viewY = y;
}
scrollToInstance(instance: gdInitialInstance) {
this.viewX = instance.getX();
this.viewY = instance.getY();
/**
* Moves view to the rectangle center and returns the ideal zoom
* factor to fit to the rectangle.
*/
fitToRectangle(rectangle: Rectangle): number {
this.viewX = rectangle.centerX();
this.viewY = rectangle.centerY();
const idealZoomOnX = this._width / rectangle.width();
const idealZoomOnY = this._height / rectangle.height();
return Math.min(idealZoomOnX, idealZoomOnY) * 0.95; // Add margin so that the object doesn't feel cut
}
getViewX() {

View File

@@ -2,6 +2,7 @@
import * as PIXI from 'pixi.js-legacy';
import transformRect from '../Utils/TransformRect';
import { rgbToHexNumber } from '../Utils/ColorTransformer';
import Rectangle from '../Utils/Rectangle';
type Props = {|
project: gdProject,
@@ -14,7 +15,7 @@ export default class WindowBorder {
layout: gdLayout;
toCanvasCoordinates: (x: number, y: number) => [number, number];
pixiRectangle = new PIXI.Graphics();
windowRectangle: { x: number, y: number, width: number, height: number };
windowRectangle: Rectangle;
constructor({ project, layout, toCanvasCoordinates }: Props) {
this.project = project;
@@ -22,12 +23,12 @@ export default class WindowBorder {
this.toCanvasCoordinates = toCanvasCoordinates;
this.pixiRectangle.hitArea = new PIXI.Rectangle(0, 0, 0, 0);
this.windowRectangle = {
x: 0,
y: 0,
width: this.project.getGameResolutionWidth(),
height: this.project.getGameResolutionHeight(),
};
this.windowRectangle = new Rectangle(
0,
0,
this.project.getGameResolutionWidth(),
this.project.getGameResolutionHeight()
);
}
getPixiObject() {
@@ -35,9 +36,6 @@ export default class WindowBorder {
}
render() {
this.windowRectangle.width = this.project.getGameResolutionWidth();
this.windowRectangle.height = this.project.getGameResolutionHeight();
const displayedRectangle = transformRect(
this.toCanvasCoordinates,
this.windowRectangle
@@ -57,10 +55,10 @@ export default class WindowBorder {
this.pixiRectangle.alpha = 1;
this.pixiRectangle.fill.alpha = 0;
this.pixiRectangle.drawRect(
displayedRectangle.x,
displayedRectangle.y,
displayedRectangle.width,
displayedRectangle.height
displayedRectangle.left,
displayedRectangle.top,
displayedRectangle.width(),
displayedRectangle.height()
);
this.pixiRectangle.endFill();
}

View File

@@ -3,6 +3,7 @@ import * as PIXI from 'pixi.js-legacy';
import transformRect from '../Utils/TransformRect';
import ViewPosition from './ViewPosition';
import { type InstancesEditorSettings } from './InstancesEditorSettings';
import Rectangle from '../Utils/Rectangle';
type Props = {|
project: gdProject,
@@ -15,12 +16,7 @@ export default class WindowMask {
instancesEditorSettings: InstancesEditorSettings;
viewPosition: ViewPosition;
pixiRectangle = new PIXI.Graphics();
windowRectangle = {
x: 0,
y: 0,
width: 0,
height: 0,
};
windowRectangle: Rectangle = new Rectangle();
constructor({ project, viewPosition, instancesEditorSettings }: Props) {
this.project = project;
@@ -46,10 +42,12 @@ export default class WindowMask {
const width = this.project.getGameResolutionWidth();
const height = this.project.getGameResolutionHeight();
this.windowRectangle.x = this.viewPosition.getViewX() - width / 2;
this.windowRectangle.y = this.viewPosition.getViewY() - height / 2;
this.windowRectangle.width = width;
this.windowRectangle.height = height;
this.windowRectangle.setThroughCenter({
centerX: this.viewPosition.getViewX(),
centerY: this.viewPosition.getViewY(),
width,
height,
});
const displayedRectangle = transformRect(
this.viewPosition.toCanvasCoordinates,
@@ -63,10 +61,10 @@ export default class WindowMask {
this.pixiRectangle.alpha = 1;
this.pixiRectangle.fill.alpha = 0;
this.pixiRectangle.drawRect(
displayedRectangle.x,
displayedRectangle.y,
displayedRectangle.width,
displayedRectangle.height
displayedRectangle.left,
displayedRectangle.top,
displayedRectangle.width(),
displayedRectangle.height()
);
this.pixiRectangle.endFill();
}

View File

@@ -1,7 +1,7 @@
// @flow
import React, { Component } from 'react';
import gesture from 'pixi-simple-gesture';
import DeprecatedKeyboardShortcuts from '../UI/KeyboardShortcuts/DeprecatedKeyboardShortcuts';
import KeyboardShortcuts from '../UI/KeyboardShortcuts';
import InstancesRenderer from './InstancesRenderer';
import ViewPosition from './ViewPosition';
import SelectedInstances from './SelectedInstances';
@@ -31,6 +31,8 @@ import { type ScreenType } from '../UI/Reponsive/ScreenTypeMeasurer';
import InstancesSelection from './InstancesSelection';
import LongTouchHandler from './LongTouchHandler';
import { type InstancesEditorSettings } from './InstancesEditorSettings';
import Rectangle from '../Utils/Rectangle';
const gd: libGDevelop = global.gd;
const styles = {
canvasArea: { flex: 1, position: 'absolute', overflow: 'hidden' },
@@ -39,6 +41,20 @@ const styles = {
const DropTarget = makeDropTarget<{||}>(objectWithContextReactDndType);
export type InstancesEditorShortcutsCallbacks = {|
onDelete: () => void,
onCopy: () => void,
onCut: () => void,
onPaste: () => void,
onUndo: () => void,
onRedo: () => void,
onZoomOut: () => void,
onZoomIn: () => void,
onShift1: () => void,
onShift2: () => void,
onShift3: () => void,
|};
export type InstancesEditorPropsWithoutSizeAndScroll = {|
project: gdProject,
layout: gdLayout,
@@ -48,7 +64,6 @@ export type InstancesEditorPropsWithoutSizeAndScroll = {|
instancesEditorSettings: InstancesEditorSettings
) => void,
instancesSelection: InstancesSelection,
onDeleteSelection: () => void,
onInstancesAdded: (instances: Array<gdInitialInstance>) => void,
onInstancesSelected: (instances: Array<gdInitialInstance>) => void,
onInstanceDoubleClicked: (instance: gdInitialInstance) => void,
@@ -61,14 +76,8 @@ export type InstancesEditorPropsWithoutSizeAndScroll = {|
y: number,
ignoreSelectedObjectNamesForContextMenu?: boolean
) => void,
onCopy: () => void,
onCut: () => void,
onPaste: () => void,
onUndo: () => void,
onRedo: () => void,
onZoomOut: () => void,
onZoomIn: () => void,
pauseRendering: boolean,
instancesEditorShortcutsCallbacks: InstancesEditorShortcutsCallbacks,
|};
type Props = {|
@@ -87,7 +96,7 @@ export default class InstancesEditor extends Component<Props> {
fpsLimiter = new FpsLimiter(28);
canvasArea: ?HTMLDivElement;
pixiRenderer: PIXI.Renderer;
keyboardShortcuts: DeprecatedKeyboardShortcuts;
keyboardShortcuts: KeyboardShortcuts;
pinchHandler: PinchHandler;
canvasCursor: CanvasCursor;
_instancesAdder: InstancesAdder;
@@ -137,6 +146,13 @@ export default class InstancesEditor extends Component<Props> {
// if the project changes).
const { project } = this.props;
this.keyboardShortcuts = new KeyboardShortcuts({
shortcutCallbacks: {
onMove: this.moveSelection,
...this.props.instancesEditorShortcutsCallbacks,
},
});
//Create the renderer and setup the rendering area for scene editor.
//"preserveDrawingBuffer: true" is needed to avoid flickering and background issues on some mobile phones (see #585 #572 #566 #463)
this.pixiRenderer = PIXI.autoDetectRenderer(
@@ -176,18 +192,22 @@ export default class InstancesEditor extends Component<Props> {
event.preventDefault();
};
this.pixiRenderer.view.setAttribute('tabIndex', -1);
this.pixiRenderer.view.addEventListener('focus', e => {
this.keyboardShortcuts.focus();
});
this.pixiRenderer.view.addEventListener('blur', e => {
this.keyboardShortcuts.blur();
});
this.pixiRenderer.view.addEventListener('mouseover', e => {
this.keyboardShortcuts.focus();
});
this.pixiRenderer.view.addEventListener('mouseout', e => {
this.keyboardShortcuts.blur();
});
this.pixiRenderer.view.addEventListener(
'keydown',
this.keyboardShortcuts.onKeyDown
);
this.pixiRenderer.view.addEventListener(
'keyup',
this.keyboardShortcuts.onKeyUp
);
this.pixiRenderer.view.addEventListener(
'mousedown',
this.keyboardShortcuts.onMouseDown
);
this.pixiRenderer.view.addEventListener(
'mouseup',
this.keyboardShortcuts.onMouseUp
);
this.pixiContainer = new PIXI.Container();
@@ -257,18 +277,6 @@ export default class InstancesEditor extends Component<Props> {
});
this.pixiContainer.addChild(this.grid.getPixiObject());
this.keyboardShortcuts = new DeprecatedKeyboardShortcuts({
onDelete: this.props.onDeleteSelection,
onMove: this.moveSelection,
onCopy: this.props.onCopy,
onCut: this.props.onCut,
onPaste: this.props.onPaste,
onUndo: this.props.onUndo,
onRedo: this.props.onRedo,
onZoomOut: this.props.onZoomOut,
onZoomIn: this.props.onZoomIn,
});
this.pinchHandler = new PinchHandler({
canvas: this.pixiRenderer.view,
setZoomFactor: this.setZoomFactor,
@@ -408,7 +416,6 @@ export default class InstancesEditor extends Component<Props> {
// to protect against renders after the component is unmounted.
this._unmounted = true;
this.keyboardShortcuts.unmount();
this.selectionRectangle.delete();
this.instancesRenderer.delete();
this._instancesAdder.unmount();
@@ -804,16 +811,75 @@ export default class InstancesEditor extends Component<Props> {
this.viewPosition.scrollTo(x, y);
}
centerView() {
zoomToFitContent() {
const { initialInstances } = this.props;
if (initialInstances.getInstancesCount() === 0) return;
const instanceMeasurer = this.instancesRenderer.getInstanceMeasurer();
let contentAABB: ?Rectangle;
const getInstanceRectangle = new gd.InitialInstanceJSFunctor();
// $FlowFixMe - invoke is not writable
getInstanceRectangle.invoke = instancePtr => {
// $FlowFixMe - wrapPointer is not exposed
const instance: gdInitialInstance = gd.wrapPointer(
instancePtr,
gd.InitialInstance
);
if (!contentAABB) {
contentAABB = instanceMeasurer.getInstanceAABB(
instance,
new Rectangle()
);
} else {
contentAABB.union(
instanceMeasurer.getInstanceAABB(instance, new Rectangle())
);
}
};
// $FlowFixMe - JSFunctor is incompatible with Functor
initialInstances.iterateOverInstances(getInstanceRectangle);
getInstanceRectangle.delete();
if (contentAABB) {
const idealZoom = this.viewPosition.fitToRectangle(contentAABB);
this.setZoomFactor(idealZoom);
}
}
zoomToInitialPosition() {
const x = this.props.project.getGameResolutionWidth() / 2;
const y = this.props.project.getGameResolutionHeight() / 2;
this.viewPosition.scrollTo(x, y);
this.setZoomFactor(1);
}
centerViewOn(instances: Array<gdInitialInstance>) {
if (!instances.length) return;
zoomToFitSelection(instances: Array<gdInitialInstance>) {
if (instances.length === 0) return;
const [firstInstance, ...otherInstances] = instances;
const instanceMeasurer = this.instancesRenderer.getInstanceMeasurer();
let selectedInstancesRectangle = instanceMeasurer.getInstanceAABB(
firstInstance,
new Rectangle()
);
otherInstances.forEach(instance => {
selectedInstancesRectangle.union(
instanceMeasurer.getInstanceAABB(instance, new Rectangle())
);
});
const idealZoom = this.viewPosition.fitToRectangle(
selectedInstancesRectangle
);
this.setZoomFactor(idealZoom);
}
this.viewPosition.scrollToInstance(instances[instances.length - 1]);
centerViewOnLastInstance(instances: Array<gdInitialInstance>) {
if (instances.length === 0) return;
const instanceMeasurer = this.instancesRenderer.getInstanceMeasurer();
let lastInstanceRectangle = instanceMeasurer.getInstanceAABB(
instances[instances.length - 1],
new Rectangle()
);
this.viewPosition.fitToRectangle(lastInstanceRectangle);
if (this.props.onViewPositionChanged) {
this.props.onViewPositionChanged(this.viewPosition);
}

View File

@@ -89,6 +89,12 @@ const jsExtensions = [
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Leaderboards/JsExtension.js'),
objectsRenderingServiceModules: {},
},
{
name: 'PlayerAuthentication',
// $FlowExpectedError - this path is ignored for Flow.
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/PlayerAuthentication/JsExtension.js'),
objectsRenderingServiceModules: {},
},
{
name: 'DialogueTree',
// $FlowExpectedError - this path is ignored for Flow.

View File

@@ -52,6 +52,7 @@ const defaultShortcuts: ShortcutMap = {
ADD_SUBEVENT: 'Shift+KeyD',
ADD_COMMENT_EVENT: '',
TOGGLE_EVENT_DISABLED: 'KeyD',
TOGGLE_CONDITION_INVERTED: 'KeyJ',
CHOOSE_AND_ADD_EVENT: 'Shift+KeyW',
OPEN_EXTENSION_SETTINGS: '',
};

View File

@@ -214,6 +214,7 @@ export default class SpritesList extends Component<Props, void> {
copySpritePolygons(direction.getSprite(0), sprite);
}
direction.addSprite(sprite);
sprite.delete();
});
// Important, we are responsible for deleting the resources that were given to us.

View File

@@ -331,6 +331,7 @@ export const duplicateSpritesInAnimation = (
direction.addSprite(newSprite);
direction.moveSprite(direction.getSpritesCount() - 1, spriteIndex);
newSprite.delete();
}
}
});

View File

@@ -224,6 +224,7 @@ class AnimationsListContainer extends React.Component<
const emptyAnimation = new gd.Animation();
emptyAnimation.setDirectionsCount(1);
this.props.spriteConfiguration.addAnimation(emptyAnimation);
emptyAnimation.delete();
this.forceUpdate();
this.props.onSizeUpdated();
};

View File

@@ -77,7 +77,10 @@ const getObjectsAndGroupsDataSource = ({
? () => (
<ListIcon
iconSize={iconSize}
src={ObjectsRenderingService.getThumbnail(project, object)}
src={ObjectsRenderingService.getThumbnail(
project,
object.getConfiguration()
)}
/>
)
: undefined,

View File

@@ -16,7 +16,7 @@ const generateLayoutObjectsOptions = (
handler: () => onChoose(item.object, onChooseArg),
iconSrc: ObjectsRenderingService.getThumbnail.bind(ObjectsRenderingService)(
project,
item.object
item.object.getConfiguration()
),
}));
};

View File

@@ -47,6 +47,7 @@ import {
} from '../ResourcesList/ResourceSource';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
import { type OnFetchNewlyAddedResourcesFunction } from '../ProjectsStorage/ResourceFetcher';
import { getInstanceCountInLayoutForObject } from '../Utils/Layout';
const gd: libGDevelop = global.gd;
const styles = {
@@ -109,6 +110,7 @@ type Props = {|
onChooseResource: ChooseResourceFunction,
resourceExternalEditors: Array<ResourceExternalEditor>,
onFetchNewlyAddedResources: OnFetchNewlyAddedResourcesFunction,
onSelectAllInstancesOfObjectInLayout?: string => void,
onDeleteObject: (
objectWithContext: ObjectWithContext,
cb: (boolean) => void
@@ -131,7 +133,10 @@ type Props = {|
canRenameObject: (newName: string) => boolean,
onAddObjectInstance: (objectName: string) => void,
getThumbnail: (project: gdProject, object: gdObject) => string,
getThumbnail: (
project: gdProject,
objectConfiguration: gdObjectConfiguration
) => string,
unsavedChanges?: ?UnsavedChanges,
hotReloadPreviewButtonProps: HotReloadPreviewButtonProps,
|};
@@ -187,8 +192,11 @@ export default class ObjectsList extends React.Component<Props, State> {
onObjectSelected,
} = this.props;
const defaultName = project.hasEventsBasedObject(objectType)
? 'New' + project.getEventsBasedObject(objectType).getDefaultName()
: objectTypeToDefaultName[objectType] || 'NewObject';
const name = newNameGenerator(
objectTypeToDefaultName[objectType] || 'NewObject',
defaultName,
name =>
objectsContainer.hasObjectNamed(name) || project.hasObjectNamed(name)
);
@@ -514,13 +522,21 @@ export default class ObjectsList extends React.Component<Props, State> {
};
_getObjectThumbnail = (objectWithContext: ObjectWithContext) =>
this.props.getThumbnail(this.props.project, objectWithContext.object);
this.props.getThumbnail(
this.props.project,
objectWithContext.object.getConfiguration()
);
_renderObjectMenuTemplate = (i18n: I18nType) => (
objectWithContext: ObjectWithContext,
index: number
) => {
const { object } = objectWithContext;
const { layout, onSelectAllInstancesOfObjectInLayout } = this.props;
const instanceCountOnScene = layout
? getInstanceCountInLayoutForObject(layout, object.getName())
: undefined;
const objectMetadata = gd.MetadataProvider.getObjectMetadata(
this.props.project.getCurrentPlatform(),
object.getType()
@@ -593,12 +609,21 @@ export default class ObjectsList extends React.Component<Props, State> {
label: i18n._(t`Add instance to the scene`),
click: () => this.props.onAddObjectInstance(object.getName()),
},
instanceCountOnScene !== undefined && onSelectAllInstancesOfObjectInLayout
? {
label: i18n._(
t`Select instances on scene (${instanceCountOnScene})`
),
click: () => onSelectAllInstancesOfObjectInLayout(object.getName()),
enabled: instanceCountOnScene > 0,
}
: undefined,
{ type: 'separator' },
{
label: i18n._(t`Add a new object...`),
click: () => this.onAddNewObject(),
},
];
].filter(Boolean);
};
_onObjectModified = (shouldForceUpdateList: boolean) => {

View File

@@ -43,20 +43,30 @@ const ObjectsRenderingService = {
'TextEntryObject::TextEntry': RenderedTextEntryInstance,
'ParticleSystem::ParticleEmitter': RenderedParticleEmitterInstance,
},
getThumbnail: function(project: gdProject, object: gdObject) {
const objectType = object.getType();
getThumbnail: function(
project: gdProject,
objectConfiguration: gdObjectConfiguration
) {
const objectType = objectConfiguration.getType();
if (this.renderers.hasOwnProperty(objectType))
return this.renderers[objectType].getThumbnail(
project,
ResourcesLoader,
object
objectConfiguration
);
else
else if (project.hasEventsBasedObject(objectType)) {
return RenderedCustomObjectInstance.getThumbnail(
project,
ResourcesLoader,
objectConfiguration
);
} else {
return this.renderers['unknownObjectType'].getThumbnail(
project,
ResourcesLoader,
object
objectConfiguration
);
}
},
createNewInstanceRenderer: function(
project: gdProject,

View File

@@ -3,7 +3,8 @@ import RenderedInstance from './RenderedInstance';
import PixiResourcesLoader from '../../ObjectsRendering/PixiResourcesLoader';
import ResourcesLoader from '../../ResourcesLoader';
import ObjectsRenderingService from '../ObjectsRenderingService';
import { mapFor } from '../../Utils/MapFor';
import RenderedTextInstance from './RenderedTextInstance';
import { mapReverseFor } from '../../Utils/MapFor';
import * as PIXI from 'pixi.js-legacy';
const gd: libGDevelop = global.gd;
@@ -13,10 +14,16 @@ const gd: libGDevelop = global.gd;
class ChildInstance {
x: number;
y: number;
_hasCustomSize: boolean;
_customWidth: number;
_customHeight: number;
constructor() {
this.x = 0;
this.y = 0;
this._customWidth = 0;
this._customHeight = 0;
this._hasCustomSize = false;
}
getX() {
@@ -67,22 +74,30 @@ class ChildInstance {
setLayer(layer: string) {}
setHasCustomSize(enable: boolean) {}
setHasCustomSize(enable: boolean) {
this._hasCustomSize = enable;
}
hasCustomSize() {
return false;
return this._hasCustomSize;
}
setCustomWidth(width: number) {}
setCustomWidth(width: number) {
this._customWidth = width;
this._hasCustomSize = true;
}
getCustomWidth() {
return 0;
return this._customWidth;
}
setCustomHeight(height: number) {}
setCustomHeight(height: number) {
this._customHeight = height;
this._hasCustomSize = true;
}
getCustomHeight() {
return 0;
return this._customHeight;
}
resetPersistentUuid() {
@@ -161,14 +176,14 @@ export default class RenderedCustomObjectInstance extends RenderedInstance {
this.childrenInstances = [];
this.childrenRenderedInstances = eventBasedObject
? mapFor(0, eventBasedObject.getObjectsCount(), i => {
? mapReverseFor(0, eventBasedObject.getObjectsCount(), i => {
const childObject = eventBasedObject.getObjectAt(i);
const childObjectConfiguration = customObjectConfiguration.getChildObjectConfiguration(
childObject.getName()
);
const childInstance = new ChildInstance();
this.childrenInstances.push(childInstance);
return ObjectsRenderingService.createNewInstanceRenderer(
const renderer = ObjectsRenderingService.createNewInstanceRenderer(
project,
layout,
// $FlowFixMe Use real object instances.
@@ -176,6 +191,11 @@ export default class RenderedCustomObjectInstance extends RenderedInstance {
childObjectConfiguration,
this._pixiObject
);
if (renderer instanceof RenderedTextInstance) {
// TODO EBO Remove this line when an alignment property is added to the text object.
renderer._pixiObject.style.align = 'center';
}
return renderer;
})
: [];
}
@@ -186,8 +206,38 @@ export default class RenderedCustomObjectInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
const customObjectConfiguration = gd.asCustomObjectConfiguration(
objectConfiguration
);
const eventBasedObject = project.hasEventsBasedObject(
customObjectConfiguration.getType()
)
? project.getEventsBasedObject(customObjectConfiguration.getType())
: null;
if (!eventBasedObject) {
return 'res/unknown32.png';
}
for (let i = 0; i < eventBasedObject.getObjectsCount(); i++) {
const childObject = eventBasedObject.getObjectAt(i);
const childObjectConfiguration = customObjectConfiguration.getChildObjectConfiguration(
childObject.getName()
);
const childType = childObjectConfiguration.getType();
if (
childType === 'Sprite' ||
childType === 'TiledSpriteObject::TiledSprite' ||
childType === 'PanelSpriteObject::PanelSprite'
) {
return ObjectsRenderingService.getThumbnail(
project,
childObjectConfiguration
);
}
}
return 'res/unknown32.png';
}
@@ -199,6 +249,13 @@ export default class RenderedCustomObjectInstance extends RenderedInstance {
const centerX = defaultWidth / 2;
const centerY = defaultHeight / 2;
const width = this._instance.hasCustomSize()
? this._instance.getCustomWidth()
: this.getDefaultWidth();
const height = this._instance.hasCustomSize()
? this._instance.getCustomHeight()
: this.getDefaultHeight();
for (
let index = 0;
index < this.childrenRenderedInstances.length;
@@ -206,9 +263,22 @@ export default class RenderedCustomObjectInstance extends RenderedInstance {
) {
const renderedInstance = this.childrenRenderedInstances[index];
const childInstance = this.childrenInstances[index];
childInstance.x = (defaultWidth - renderedInstance.getDefaultWidth()) / 2;
childInstance.y =
(defaultHeight - renderedInstance.getDefaultHeight()) / 2;
childInstance.x = 0;
childInstance.y = 0;
childInstance.setCustomWidth(width);
childInstance.setCustomHeight(height);
renderedInstance.update();
if (renderedInstance instanceof RenderedTextInstance) {
// TODO EBO Remove this line when an alignment property is added to the text object.
renderedInstance._pixiObject.style.align = 'center';
}
// This ensure objects are centered if their dimensions changed from the
// custom ones (preferred ones).
// For instance, text object dimensions change according to how the text is wrapped.
childInstance.x = (width - renderedInstance._pixiObject.width) / 2;
childInstance.y = (height - renderedInstance._pixiObject.height) / 2;
renderedInstance.update();
}
@@ -217,14 +287,8 @@ export default class RenderedCustomObjectInstance extends RenderedInstance {
this._pixiObject.rotation = RenderedInstance.toRad(
this._instance.getAngle()
);
if (this._instance.hasCustomSize()) {
this._pixiObject.scale.x = this._instance.getCustomWidth() / defaultWidth;
this._pixiObject.scale.y =
this._instance.getCustomHeight() / defaultHeight;
} else {
this._pixiObject.scale.x = 1;
this._pixiObject.scale.y = 1;
}
this._pixiObject.scale.x = 1;
this._pixiObject.scale.y = 1;
this._pixiObject.position.x =
this._instance.getX() +
(centerX - originX) * Math.abs(this._pixiObject.scale.x);

View File

@@ -39,7 +39,7 @@ export default function makeRenderer(iconPath: string) {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
return iconPath;
}

View File

@@ -413,11 +413,9 @@ export default class RenderedPanelSpriteInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
const panelSprite = gd.asPanelSpriteConfiguration(
object.getConfiguration()
);
const panelSprite = gd.asPanelSpriteConfiguration(objectConfiguration);
return ResourcesLoader.getResourceFullUrl(
project,

View File

@@ -38,7 +38,7 @@ export default class RenderedParticleEmitterInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
return 'CppPlatform/Extensions/particleSystemicon.png';
}

View File

@@ -56,11 +56,9 @@ export default class RenderedSpriteInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
): string {
const spriteConfiguration = gd.asSpriteConfiguration(
object.getConfiguration()
);
const spriteConfiguration = gd.asSpriteConfiguration(objectConfiguration);
if (
spriteConfiguration.getAnimationsCount() > 0 &&

View File

@@ -60,7 +60,7 @@ export default class RenderedTextInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
return 'CppPlatform/Extensions/texticon24.png';
}

View File

@@ -49,11 +49,9 @@ export default class RenderedTiledSpriteInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
const tiledSprite = gd.asTiledSpriteConfiguration(
object.getConfiguration()
);
const tiledSprite = gd.asTiledSpriteConfiguration(objectConfiguration);
return ResourcesLoader.getResourceFullUrl(
project,

View File

@@ -44,7 +44,7 @@ export default class RenderedUnknownInstance extends RenderedInstance {
static getThumbnail(
project: gdProject,
resourcesLoader: Class<ResourcesLoader>,
object: gdObject
objectConfiguration: gdObjectConfiguration
) {
return 'res/unknown32.png';
}

View File

@@ -182,6 +182,7 @@ export default function DownloadFileSaveAsDialog({ project, onDone }: Props) {
const serializedProject = new gd.SerializerElement();
project.serializeTo(serializedProject);
newProject.unserializeFrom(serializedProject);
serializedProject.delete();
// Download resources to blobs, and update the project resources.
const blobFiles: Array<BlobFileDescriptor> = [];

View File

@@ -1,13 +1,14 @@
// @flow
import { t } from '@lingui/macro';
import { type I18n as I18nType } from '@lingui/core';
import React, { PureComponent } from 'react';
import * as React from 'react';
import { ToolbarGroup } from '../UI/Toolbar';
import ToolbarSeparator from '../UI/ToolbarSeparator';
import ToolbarIcon from '../UI/ToolbarIcon';
import ElementWithMenu from '../UI/Menu/ElementWithMenu';
import ToolbarCommands from './ToolbarCommands';
import InstancesSelection from '../InstancesEditor/InstancesSelection';
import { type MenuItemTemplate } from '../UI/Menu/Menu.flow';
type Props = {|
openObjectsList: () => void,
@@ -26,156 +27,137 @@ type Props = {|
isGridShown: () => boolean,
toggleGrid: () => void,
openSetupGrid: () => void,
zoomIn: () => void,
zoomOut: () => void,
centerView: () => void,
getContextMenuZoomItems: I18nType => Array<MenuItemTemplate>,
setZoomFactor: number => void,
onOpenSettings?: ?() => void,
|};
export class Toolbar extends PureComponent<Props> {
render() {
return (
<>
<ToolbarCommands
openObjectsList={this.props.openObjectsList}
openObjectGroupsList={this.props.openObjectGroupsList}
openPropertiesPanel={this.props.openProperties}
toggleInstancesList={this.props.toggleInstancesList}
toggleLayersList={this.props.toggleLayersList}
undo={this.props.undo}
canUndo={this.props.canUndo}
redo={this.props.redo}
canRedo={this.props.canRedo}
deleteSelection={this.props.deleteSelection}
toggleWindowMask={this.props.toggleWindowMask}
toggleGrid={this.props.toggleGrid}
setupGrid={this.props.openSetupGrid}
canDeleteSelection={
this.props.instancesSelection.getSelectedInstances().length !== 0
}
const Toolbar = (props: Props) => {
return (
<>
<ToolbarCommands
openObjectsList={props.openObjectsList}
openObjectGroupsList={props.openObjectGroupsList}
openPropertiesPanel={props.openProperties}
toggleInstancesList={props.toggleInstancesList}
toggleLayersList={props.toggleLayersList}
undo={props.undo}
canUndo={props.canUndo}
redo={props.redo}
canRedo={props.canRedo}
deleteSelection={props.deleteSelection}
toggleWindowMask={props.toggleWindowMask}
toggleGrid={props.toggleGrid}
setupGrid={props.openSetupGrid}
canDeleteSelection={
props.instancesSelection.getSelectedInstances().length !== 0
}
/>
<ToolbarGroup lastChild>
<ToolbarIcon
onClick={props.openObjectsList}
src="res/ribbon_default/objects64.png"
tooltip={t`Open the objects editor`}
/>
<ToolbarGroup lastChild>
<ToolbarIcon
onClick={this.props.openObjectsList}
src="res/ribbon_default/objects64.png"
tooltip={t`Open the objects editor`}
/>
<ToolbarIcon
onClick={this.props.openObjectGroupsList}
src={'res/ribbon_default/objectsgroups64.png'}
tooltip={t`Open the objects groups editor`}
/>
<ToolbarIcon
onClick={this.props.openProperties}
src="res/ribbon_default/editprop32.png"
tooltip={t`Open the properties panel`}
/>
<ToolbarIcon
onClick={this.props.toggleInstancesList}
src="res/ribbon_default/ObjectsPositionsList32.png"
tooltip={t`Open the list of instances`}
/>
<ToolbarIcon
onClick={this.props.toggleLayersList}
src="res/ribbon_default/layers32.png"
tooltip={t`Open the layers editor`}
/>
<ToolbarSeparator />
<ToolbarIcon
onClick={this.props.undo}
src="res/ribbon_default/undo32.png"
disabled={!this.props.canUndo}
tooltip={t`Undo the last changes`}
/>
<ToolbarIcon
onClick={this.props.redo}
src="res/ribbon_default/redo32.png"
disabled={!this.props.canRedo}
tooltip={t`Redo the last changes`}
/>
<ToolbarSeparator />
<ToolbarIcon
onClick={this.props.deleteSelection}
src="res/ribbon_default/deleteselected32.png"
disabled={
!this.props.instancesSelection.getSelectedInstances().length
}
tooltip={t`Delete the selected instances from the scene`}
/>
<ToolbarSeparator />
<ElementWithMenu
element={
<ToolbarIcon
src="res/ribbon_default/grid32.png"
tooltip={t`Toggle/edit grid`}
/>
}
buildMenuTemplate={(i18n: I18nType) => [
{
type: 'checkbox',
label: i18n._(t`Show Mask`),
checked: this.props.isWindowMaskShown(),
click: () => this.props.toggleWindowMask(),
},
{
type: 'checkbox',
label: i18n._(t`Show grid`),
checked: this.props.isGridShown(),
click: () => this.props.toggleGrid(),
},
{ type: 'separator' },
{
label: i18n._(t`Setup grid`),
click: () => this.props.openSetupGrid(),
},
]}
/>
<ElementWithMenu
element={
<ToolbarIcon
src="res/ribbon_default/zoom32.png"
tooltip={t`Change editor zoom`}
/>
}
buildMenuTemplate={(i18n: I18nType) => [
{
label: i18n._(t`Zoom in`),
click: this.props.zoomIn,
accelerator: 'CmdOrCtrl+numadd',
},
{
label: i18n._(t`Zoom out`),
click: this.props.zoomOut,
accelerator: 'CmdOrCtrl+numsub',
},
{
label: i18n._(t`Center View`),
click: () => this.props.centerView(),
},
{ type: 'separator' },
{ label: '5%', click: () => this.props.setZoomFactor(0.05) },
{ label: '10%', click: () => this.props.setZoomFactor(0.1) },
{ label: '25%', click: () => this.props.setZoomFactor(0.25) },
{ label: '50%', click: () => this.props.setZoomFactor(0.5) },
{ label: '100%', click: () => this.props.setZoomFactor(1.0) },
{ label: '150%', click: () => this.props.setZoomFactor(1.5) },
{ label: '200%', click: () => this.props.setZoomFactor(2.0) },
{ label: '400%', click: () => this.props.setZoomFactor(4.0) },
]}
/>
{this.props.onOpenSettings && <ToolbarSeparator />}
{this.props.onOpenSettings && (
<ToolbarIcon
onClick={props.openObjectGroupsList}
src={'res/ribbon_default/objectsgroups64.png'}
tooltip={t`Open the objects groups editor`}
/>
<ToolbarIcon
onClick={props.openProperties}
src="res/ribbon_default/editprop32.png"
tooltip={t`Open the properties panel`}
/>
<ToolbarIcon
onClick={props.toggleInstancesList}
src="res/ribbon_default/ObjectsPositionsList32.png"
tooltip={t`Open the list of instances`}
/>
<ToolbarIcon
onClick={props.toggleLayersList}
src="res/ribbon_default/layers32.png"
tooltip={t`Open the layers editor`}
/>
<ToolbarSeparator />
<ToolbarIcon
onClick={props.undo}
src="res/ribbon_default/undo32.png"
disabled={!props.canUndo}
tooltip={t`Undo the last changes`}
/>
<ToolbarIcon
onClick={props.redo}
src="res/ribbon_default/redo32.png"
disabled={!props.canRedo}
tooltip={t`Redo the last changes`}
/>
<ToolbarSeparator />
<ToolbarIcon
onClick={props.deleteSelection}
src="res/ribbon_default/deleteselected32.png"
disabled={!props.instancesSelection.getSelectedInstances().length}
tooltip={t`Delete the selected instances from the scene`}
/>
<ToolbarSeparator />
<ElementWithMenu
element={
<ToolbarIcon
onClick={this.props.onOpenSettings}
src="res/ribbon_default/pref32.png"
tooltip={t`Open settings`}
src="res/ribbon_default/grid32.png"
tooltip={t`Toggle/edit grid`}
/>
)}
</ToolbarGroup>
</>
);
}
}
}
buildMenuTemplate={(i18n: I18nType) => [
{
type: 'checkbox',
label: i18n._(t`Show Mask`),
checked: props.isWindowMaskShown(),
click: () => props.toggleWindowMask(),
},
{
type: 'checkbox',
label: i18n._(t`Show grid`),
checked: props.isGridShown(),
click: () => props.toggleGrid(),
},
{ type: 'separator' },
{
label: i18n._(t`Setup grid`),
click: () => props.openSetupGrid(),
},
]}
/>
<ElementWithMenu
element={
<ToolbarIcon
src="res/ribbon_default/zoom32.png"
tooltip={t`Change editor zoom`}
/>
}
buildMenuTemplate={(i18n: I18nType) => [
...props.getContextMenuZoomItems(i18n),
{ type: 'separator' },
{ label: '5%', click: () => props.setZoomFactor(0.05) },
{ label: '10%', click: () => props.setZoomFactor(0.1) },
{ label: '25%', click: () => props.setZoomFactor(0.25) },
{ label: '50%', click: () => props.setZoomFactor(0.5) },
{ label: '100%', click: () => props.setZoomFactor(1.0) },
{ label: '150%', click: () => props.setZoomFactor(1.5) },
{ label: '200%', click: () => props.setZoomFactor(2.0) },
{ label: '400%', click: () => props.setZoomFactor(4.0) },
]}
/>
{props.onOpenSettings && <ToolbarSeparator />}
{props.onOpenSettings && (
<ToolbarIcon
onClick={props.onOpenSettings}
src="res/ribbon_default/pref32.png"
tooltip={t`Open settings`}
/>
)}
</ToolbarGroup>
</>
);
};
export default Toolbar;

View File

@@ -74,8 +74,9 @@ import { onObjectAdded, onInstanceAdded } from '../Hints/ObjectsAdditionalWork';
import { type InfoBarDetails } from '../Hints/ObjectsAdditionalWork';
import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton';
import EventsRootVariablesFinder from '../Utils/EventsRootVariablesFinder';
import { MOVEMENT_BIG_DELTA } from '../UI/KeyboardShortcuts/DeprecatedKeyboardShortcuts';
import { MOVEMENT_BIG_DELTA } from '../UI/KeyboardShortcuts';
import { type OnFetchNewlyAddedResourcesFunction } from '../ProjectsStorage/ResourceFetcher';
import { getInstancesInLayoutForObject } from '../Utils/Layout';
const gd: libGDevelop = global.gd;
@@ -247,13 +248,11 @@ export default class SceneEditor extends React.Component<Props, State> {
}
openSetupGrid={this.openSetupGrid}
setZoomFactor={this.setZoomFactor}
centerView={this.centerView}
getContextMenuZoomItems={this.getContextMenuZoomItems}
canUndo={canUndo(this.state.history)}
canRedo={canRedo(this.state.history)}
undo={this.undo}
redo={this.redo}
zoomIn={this.zoomIn}
zoomOut={this.zoomOut}
onOpenSettings={this.openSceneProperties}
/>
);
@@ -590,7 +589,7 @@ export default class SceneEditor extends React.Component<Props, State> {
ignoreSeal: true,
});
if (this.editor) this.editor.centerViewOn(instances);
if (this.editor) this.editor.centerViewOnLastInstance(instances);
this.forceUpdateInstancesList();
this.forceUpdatePropertiesEditor();
this.updateToolbar();
@@ -882,8 +881,48 @@ export default class SceneEditor extends React.Component<Props, State> {
);
};
centerView = () => {
if (this.editor) this.editor.centerView();
zoomToInitialPosition = () => {
if (this.editor) this.editor.zoomToInitialPosition();
};
zoomToFitContent = () => {
if (this.editor) this.editor.zoomToFitContent();
};
zoomToFitSelection = () => {
const selectedInstances = this.instancesSelection.getSelectedInstances();
if (this.editor) this.editor.zoomToFitSelection(selectedInstances);
};
getContextMenuZoomItems = (i18n: I18nType) => {
return [
{
label: i18n._(t`Zoom in`),
click: this.zoomIn,
accelerator: 'CmdOrCtrl+numadd',
},
{
label: i18n._(t`Zoom out`),
click: this.zoomOut,
accelerator: 'CmdOrCtrl+numsub',
},
{
label: i18n._(t`Zoom to fit selection`),
click: this.zoomToFitSelection,
enabled: this.instancesSelection.hasSelectedInstances(),
accelerator: 'Shift+num1',
},
{
label: i18n._(t`Zoom to initial position`),
click: this.zoomToInitialPosition,
accelerator: 'Shift+num2',
},
{
label: i18n._(t`Zoom to fit content`),
click: this.zoomToFitContent,
accelerator: 'Shift+num3',
},
];
};
setZoomFactor = (zoomFactor: number) => {
@@ -928,6 +967,8 @@ export default class SceneEditor extends React.Component<Props, State> {
label: i18n._(t`Insert new...`),
click: () => this._createNewObjectAndInstanceUnderCursor(),
},
{ type: 'separator' },
...this.getContextMenuZoomItems(i18n),
];
} else {
contextMenuItems = [
@@ -1102,6 +1143,19 @@ export default class SceneEditor extends React.Component<Props, State> {
});
};
onSelectAllInstancesOfObjectInLayout = (objectName: string) => {
const { layout } = this.props;
const instancesToSelect = getInstancesInLayoutForObject(layout, objectName);
this.instancesSelection.selectInstances({
instances: instancesToSelect,
ignoreSeal: true,
multiSelect: false,
layersVisibility: null,
});
this.forceUpdateInstancesList();
this._onInstancesSelected(instancesToSelect);
};
updateBehaviorsSharedData = () => {
const { layout, project } = this.props;
layout.updateBehaviorsSharedData(project);
@@ -1270,7 +1324,6 @@ export default class SceneEditor extends React.Component<Props, State> {
instancesEditorSettings={this.state.instancesEditorSettings}
onChangeInstancesEditorSettings={this.setInstancesEditorSettings}
instancesSelection={this.instancesSelection}
onDeleteSelection={this.deleteSelection}
onInstancesAdded={this._onInstancesAdded}
onInstancesSelected={this._onInstancesSelected}
onInstanceDoubleClicked={this._onInstanceDoubleClicked}
@@ -1279,13 +1332,19 @@ export default class SceneEditor extends React.Component<Props, State> {
onInstancesRotated={this._onInstancesRotated}
selectedObjectNames={this.state.selectedObjectNames}
onContextMenu={this._onContextMenu}
onCopy={() => this.copySelection({ useLastCursorPosition: true })}
onCut={() => this.cutSelection({ useLastCursorPosition: true })}
onPaste={() => this.paste({ useLastCursorPosition: true })}
onUndo={this.undo}
onRedo={this.redo}
onZoomOut={this.zoomOut}
onZoomIn={this.zoomIn}
instancesEditorShortcutsCallbacks={{
onCopy: () => this.copySelection({ useLastCursorPosition: true }),
onCut: () => this.cutSelection({ useLastCursorPosition: true }),
onPaste: () => this.paste({ useLastCursorPosition: true }),
onDelete: this.deleteSelection,
onUndo: this.undo,
onRedo: this.redo,
onZoomOut: this.zoomOut,
onZoomIn: this.zoomIn,
onShift1: this.zoomToFitSelection,
onShift2: this.zoomToInitialPosition,
onShift3: this.zoomToFitContent,
}}
wrappedEditorRef={editor => {
this.editor = editor;
}}
@@ -1319,6 +1378,9 @@ export default class SceneEditor extends React.Component<Props, State> {
objectsContainer={layout}
layout={layout}
resourceSources={resourceSources}
onSelectAllInstancesOfObjectInLayout={
this.onSelectAllInstancesOfObjectInLayout
}
resourceExternalEditors={resourceExternalEditors}
onChooseResource={onChooseResource}
selectedObjectNames={this.state.selectedObjectNames}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';
export default React.memo(props => (
<SvgIcon {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.8536 4.14645C12.0488 4.34171 12.0488 4.65829 11.8536 4.85355L4.85355 11.8536C4.65829 12.0488 4.34171 12.0488 4.14645 11.8536C3.95118 11.6583 3.95118 11.3417 4.14645 11.1464L11.1464 4.14645C11.3417 3.95118 11.6583 3.95118 11.8536 4.14645Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.14645 4.14645C4.34171 3.95118 4.65829 3.95118 4.85355 4.14645L11.8536 11.1464C12.0488 11.3417 12.0488 11.6583 11.8536 11.8536C11.6583 12.0488 11.3417 12.0488 11.1464 11.8536L4.14645 4.85355C3.95118 4.65829 3.95118 4.34171 4.14645 4.14645Z"
fill="currentColor"
/>
</SvgIcon>
));

View File

@@ -0,0 +1,13 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';
export default React.memo(props => (
<SvgIcon {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.4509 4.08872C12.6781 4.24571 12.735 4.55714 12.578 4.78431L8.14508 11.1986L8.14507 11.1987C7.42761 12.2368 5.90028 12.2566 5.15616 11.2374L5.55998 10.9426L5.15616 11.2374L3.42953 8.87255C3.26669 8.64953 3.31549 8.33673 3.53851 8.17389C3.76153 8.01106 4.07433 8.05985 4.23717 8.28288L5.9638 10.6477C6.30202 11.111 6.99628 11.102 7.32242 10.6301C7.32242 10.6301 7.32242 10.6301 7.32242 10.6301L11.7554 4.21578C11.9123 3.98861 12.2238 3.93172 12.4509 4.08872Z"
fill="currentColor"
/>
</SvgIcon>
));

View File

@@ -1,270 +0,0 @@
import { isMacLike } from '../../Utils/Platform';
export const MOVEMENT_BIG_DELTA = 5;
const CTRL_KEY = 17;
const SHIFT_KEY = 16;
const LEFT_KEY = 37;
const UP_KEY = 38;
const RIGHT_KEY = 39;
const DOWN_KEY = 40;
const BACKSPACE_KEY = 8;
const SPACE_KEY = 32;
const DELETE_KEY = 46;
const EQUAL_KEY = 187;
const MINUS_KEY = 189;
const NUMPAD_ADD = 107;
const NUMPAD_SUBTRACT = 109;
const C_KEY = 67;
const F_KEY = 70;
const V_KEY = 86;
const X_KEY = 88;
const Y_KEY = 89;
const Z_KEY = 90;
const MID_MOUSE_BUTTON = 1;
/**
* Deprecated class to handle shortcut. Don't use this anymore, prefer the non
* deprecated version that does not add listener on the document (risking catching events
* when the components are not focused).
*
* TODO: Replace InstancesEditor shortcuts by the new `KeyboardShortcuts`.
*/
export default class DeprecatedKeyboardShortcuts {
constructor({
onDelete,
onMove,
onCopy,
onCut,
onPaste,
onUndo,
onRedo,
onSearch,
onZoomOut,
onZoomIn,
}) {
this.onDelete = onDelete || this._noop;
this.onMove = onMove || this._noop;
this.onCopy = onCopy || this._noop;
this.onCut = onCut || this._noop;
this.onPaste = onPaste || this._noop;
this.onUndo = onUndo || this._noop;
this.onRedo = onRedo || this._noop;
this.onZoomOut = onZoomOut || this._noop;
this.onZoomIn = onZoomIn || this._noop;
this.onSearch = onSearch || this._noop;
this.isFocused = false;
this.shiftPressed = false;
this.rawCtrlPressed = false;
this.metaPressed = false;
this.spacePressed = false;
this.mouseMidButtonPressed = false;
this.mount();
}
shouldCloneInstances() {
return this._isControlPressed();
}
shouldMultiSelect() {
return this.shiftPressed;
}
shouldFollowAxis() {
return this.shiftPressed;
}
shouldNotSnapToGrid() {
return this.altPressed;
}
shouldResizeProportionally() {
return this.shiftPressed;
}
shouldScrollHorizontally() {
return this.altPressed;
}
shouldZoom() {
if (isMacLike()) {
return this._isControlPressed();
} else {
if (!this._isControlPressed() && !this.altPressed && !this.shiftPressed) {
return true;
} else {
return false;
}
}
}
shouldMoveView() {
return this.spacePressed || this.mouseMidButtonPressed;
}
_isControlPressed = () => {
// On macOS, meta key (Apple/Command key) acts as Control key on Windows/Linux.
return this.metaPressed || this.rawCtrlPressed;
};
_onKeyDown = evt => {
if (!this.isFocused) return;
let preventDefault = false;
if (evt.metaKey) this.metaPressed = true;
if (evt.altKey) this.altPressed = true;
if (evt.which === CTRL_KEY) this.rawCtrlPressed = true;
if (evt.which === SHIFT_KEY) this.shiftPressed = true;
if (evt.which === SPACE_KEY) this.spacePressed = true;
const textEditorSelectors = 'textarea, input, [contenteditable="true"]';
if (evt.target && evt.target.closest(textEditorSelectors)) {
return; // Something else is currently being edited.
}
if (this.onMove) {
if (evt.which === UP_KEY) {
this.shiftPressed
? this.onMove(0, -MOVEMENT_BIG_DELTA)
: this.onMove(0, -1);
} else if (evt.which === DOWN_KEY) {
this.shiftPressed
? this.onMove(0, MOVEMENT_BIG_DELTA)
: this.onMove(0, 1);
} else if (evt.which === LEFT_KEY) {
this.shiftPressed
? this.onMove(-MOVEMENT_BIG_DELTA, 0)
: this.onMove(-1, 0);
} else if (evt.which === RIGHT_KEY) {
this.shiftPressed
? this.onMove(MOVEMENT_BIG_DELTA, 0)
: this.onMove(1, 0);
}
}
if (evt.which === BACKSPACE_KEY || evt.which === DELETE_KEY) {
this.onDelete();
preventDefault = true;
}
if (this._isControlPressed() && evt.which === C_KEY) {
this.onCopy();
}
if (this._isControlPressed() && evt.which === X_KEY) {
this.onCut();
}
if (this._isControlPressed() && evt.which === V_KEY) {
this.onPaste();
}
if (this._isControlPressed() && evt.which === Z_KEY) {
if (this.shiftPressed) {
this.onRedo();
} else {
this.onUndo();
}
}
if (this._isControlPressed() && evt.which === Y_KEY) {
this.onRedo();
}
if (this._isControlPressed() && evt.which === F_KEY) {
this.onSearch();
}
if (this._isControlPressed() && evt.which === MINUS_KEY) {
this.onZoomOut();
}
if (evt.which === NUMPAD_SUBTRACT) {
this.onZoomOut();
}
if (this._isControlPressed() && evt.which === EQUAL_KEY) {
this.onZoomIn();
}
if (evt.which === NUMPAD_ADD) {
this.onZoomIn();
}
if (preventDefault) {
if (evt.preventDefault) evt.preventDefault();
return false;
}
};
_onKeyUp = evt => {
// Always handle key up event, even if we don't have the focus,
// for modifier keys to ensure we don't lose track of their pressed/unpressed status.
if (!evt.metaKey) this.metaPressed = false;
if (!evt.altKey) this.altPressed = false;
if (evt.which === CTRL_KEY) this.rawCtrlPressed = false;
if (evt.which === SHIFT_KEY) this.shiftPressed = false;
if (evt.which === SPACE_KEY) this.spacePressed = false;
let preventDefault = false;
if (evt.which === BACKSPACE_KEY || evt.which === DELETE_KEY) {
preventDefault = true;
}
if (preventDefault) {
if (evt.preventDefault) evt.preventDefault();
return false;
}
};
_onMouseDown = evt => {
if (!this.isFocused) return;
if (!isMacLike()) {
if (evt.button === MID_MOUSE_BUTTON) {
this.mouseMidButtonPressed = true;
} else {
this.mouseMidButtonPressed = false;
}
}
};
_onMouseUp = evt => {
if (!this.isFocused) return;
if (!isMacLike() && evt.button === MID_MOUSE_BUTTON) {
this.mouseMidButtonPressed = false;
}
};
_onKeyPress = evt => {};
_noop = () => {};
focus() {
this.isFocused = true;
}
blur() {
this.isFocused = false;
// Clear these keys on blur to handle the case where app switching via
// Cmd+Tab, Win+Tab, or Alt+Tab prevents us from capturing the "keyup" event.
this.metaPressed = false;
this.altPressed = false;
}
mount() {
if (typeof document === 'undefined') return;
document.addEventListener('keydown', this._onKeyDown, true);
document.addEventListener('keyup', this._onKeyUp, true);
document.addEventListener('keypress', this._onKeyPress, true);
document.addEventListener('mousedown', this._onMouseDown, true);
document.addEventListener('mouseup', this._onMouseUp, true);
}
unmount() {
if (typeof document === 'undefined') return;
document.removeEventListener('keydown', this._onKeyDown, true);
document.removeEventListener('keyup', this._onKeyUp, true);
document.removeEventListener('keypress', this._onKeyPress, true);
document.removeEventListener('mousedown', this._onMouseDown, true);
document.removeEventListener('mouseup', this._onMouseUp, true);
}
}

View File

@@ -1,4 +1,8 @@
// @flow
import { isMacLike } from '../../Utils/Platform';
export const MOVEMENT_BIG_DELTA = 5;
const LEFT_KEY = 37;
const UP_KEY = 38;
const RIGHT_KEY = 39;
@@ -7,6 +11,7 @@ const BACKSPACE_KEY = 8;
const DELETE_KEY = 46;
const EQUAL_KEY = 187;
const MINUS_KEY = 189;
const SPACE_KEY = 32;
const NUMPAD_ADD = 107;
const NUMPAD_SUBTRACT = 109;
const C_KEY = 67;
@@ -15,8 +20,16 @@ const V_KEY = 86;
const X_KEY = 88;
const Y_KEY = 89;
const Z_KEY = 90;
const KEY_1 = 49;
const KEY_2 = 50;
const KEY_3 = 51;
const KEY_1_NUMPAD = 97;
const KEY_2_NUMPAD = 98;
const KEY_3_NUMPAD = 99;
const ESC_KEY = 27;
const MID_MOUSE_BUTTON = 1;
type ShortcutCallbacks = {|
onDelete?: () => void,
onMove?: (number, number) => void,
@@ -29,6 +42,9 @@ type ShortcutCallbacks = {|
onZoomOut?: KeyboardEvent => void,
onZoomIn?: KeyboardEvent => void,
onEscape?: () => void,
onShift1?: () => void,
onShift2?: () => void,
onShift3?: () => void,
|};
type ConstructorArgs = {|
@@ -54,6 +70,8 @@ export default class KeyboardShortcuts {
_ctrlPressed = false;
_altPressed = false;
_metaPressed = false;
_mouseMidButtonPressed = false;
_spacePressed = false;
constructor({ isActive, shortcutCallbacks }: ConstructorArgs) {
this._shortcutCallbacks = shortcutCallbacks;
@@ -68,6 +86,38 @@ export default class KeyboardShortcuts {
return this._shiftPressed;
}
shouldFollowAxis() {
return this._shiftPressed;
}
shouldNotSnapToGrid() {
return this._altPressed;
}
shouldResizeProportionally() {
return this._shiftPressed;
}
shouldScrollHorizontally() {
return this._altPressed;
}
shouldMoveView() {
return this._spacePressed || this._mouseMidButtonPressed;
}
shouldZoom() {
if (isMacLike()) {
return this._isControlOrCmdPressed();
} else {
return (
!this._isControlOrCmdPressed() &&
!this._altPressed &&
!this._shiftPressed
);
}
}
_updateModifiersFromEvent = (evt: KeyboardEvent | DragEvent) => {
this._metaPressed = evt.metaKey;
this._altPressed = evt.altKey;
@@ -75,17 +125,38 @@ export default class KeyboardShortcuts {
this._shiftPressed = evt.shiftKey;
};
_updateSpecialKeysStatus = (evt: KeyboardEvent, isDown: boolean) => {
if (evt.which === SPACE_KEY) {
this._spacePressed = isDown;
}
};
_isControlOrCmdPressed = () => {
// On macOS, meta key (Apple/Command key) acts as Control key on Windows/Linux.
return this._metaPressed || this._ctrlPressed;
};
onMouseDown = (evt: MouseEvent) => {
if (evt.button === MID_MOUSE_BUTTON) {
this._mouseMidButtonPressed = true;
} else {
this._mouseMidButtonPressed = false;
}
};
onMouseUp = (evt: MouseEvent) => {
if (evt.button === MID_MOUSE_BUTTON) {
this._mouseMidButtonPressed = false;
}
};
onDragOver = (evt: DragEvent) => {
this._updateModifiersFromEvent(evt);
};
onKeyUp = (evt: KeyboardEvent) => {
this._updateModifiersFromEvent(evt);
this._updateSpecialKeysStatus(evt, false);
};
onKeyDown = (evt: KeyboardEvent) => {
@@ -99,6 +170,8 @@ export default class KeyboardShortcuts {
return; // Something else is currently being edited.
}
this._updateSpecialKeysStatus(evt, true);
const {
onDelete,
onMove,
@@ -111,6 +184,9 @@ export default class KeyboardShortcuts {
onZoomOut,
onZoomIn,
onEscape,
onShift1,
onShift2,
onShift3,
} = this._shortcutCallbacks;
if (onMove) {
@@ -187,5 +263,29 @@ export default class KeyboardShortcuts {
evt.preventDefault();
onZoomIn(evt);
}
if (
onShift1 &&
this._shiftPressed &&
(evt.which === KEY_1 || evt.which === KEY_1_NUMPAD)
) {
evt.preventDefault();
onShift1();
}
if (
onShift2 &&
this._shiftPressed &&
(evt.which === KEY_2 || evt.which === KEY_2_NUMPAD)
) {
evt.preventDefault();
onShift2();
}
if (
onShift3 &&
this._shiftPressed &&
(evt.which === KEY_3 || evt.which === KEY_3_NUMPAD)
) {
evt.preventDefault();
onShift3();
}
};
}

View File

@@ -0,0 +1,252 @@
// @flow
import * as React from 'react';
import Measure from 'react-measure';
import { makeStyles } from '@material-ui/core/styles';
import CardMedia from '@material-ui/core/CardMedia';
import Grid from '@material-ui/core/Grid';
import { CorsAwareImage } from './CorsAwareImage';
import { Line } from './Grid';
import { shouldValidate } from './KeyboardShortcuts/InteractionKeys';
import { useResponsiveWindowWidth } from './Reponsive/ResponsiveWindowMeasurer';
import Text from './Text';
import { ColumnStackLayout } from './Layout';
const styles = {
mainImage: {
width: '100%',
// Prevent cumulative layout shift by enforcing
// the 16:9 ratio.
aspectRatio: '16 / 9',
objectFit: 'cover',
},
carouselItem: {
outlineOffset: -1,
},
imageCarouselItem: {
height: 80,
aspectRatio: '16 / 9',
display: 'block',
},
mobileImageCarouselItem: {
aspectRatio: '16 / 9',
display: 'block',
},
grid: {
overflowX: 'scroll',
overflowY: 'hidden',
},
mobileGrid: {
overflowX: 'scroll',
overflowY: 'hidden',
scrollSnapType: 'x mandatory',
},
flex: {
display: 'flex',
},
flexColumn: {
display: 'flex',
flexDirection: 'column',
},
};
const GRID_SPACING = 1;
type Props = {|
imagesUrls: Array<string>,
/**
* Alt text inserted for each image tag.
* The string `{imageIndex}` will be replaced with image actual index.
* For instance: "Asset pack preview image {imageIndex}"
*/
altTextTemplate: string,
horizontalOuterMarginToEatOnMobile?: number,
|};
const ResponsiveImagesGallery = ({
imagesUrls,
altTextTemplate,
horizontalOuterMarginToEatOnMobile,
}: Props) => {
const [selectedImageIndex, setSelectedImageIndex] = React.useState<number>(0);
const windowWidth = useResponsiveWindowWidth();
const isMobile = windowWidth === 'small';
const mobileExtremeItemsPadding =
isMobile && horizontalOuterMarginToEatOnMobile
? 2 * horizontalOuterMarginToEatOnMobile
: 0;
const classesForGridContainer = React.useMemo(
() =>
makeStyles({
'spacing-xs-1': {
marginLeft: 0,
marginRight: 0,
// Remove padding for first and last element to keep images aligned on component max width
'& > .MuiGrid-item:first-child': {
paddingLeft: mobileExtremeItemsPadding,
},
'& > .MuiGrid-item:last-child': {
paddingRight: mobileExtremeItemsPadding,
},
},
root: isMobile
? {
scrollbarHeight: 'none' /* For Firefox */,
'-ms-overflow-style': 'none' /* For Internet Explorer and Edge */,
'&::-webkit-scrollbar': {
height: 0 /* For Chrome, Safari, and Opera */,
},
}
: undefined,
}),
[mobileExtremeItemsPadding, isMobile]
)();
const [
mobileGridClientWidth,
setMobileGridClientWidth,
] = React.useState<number>(0);
const [mobileGridScrollX, setMobileGridScrollX] = React.useState(0);
const [
currentlyViewedImageIndex,
setCurrentlyViewedImageIndex,
] = React.useState<number>(0);
const mobileImageWidth =
mobileGridClientWidth -
30 - // Width kept for user to see that there's an image after or before
(horizontalOuterMarginToEatOnMobile || 0);
React.useEffect(
() => {
setCurrentlyViewedImageIndex(
Math.round(mobileGridScrollX / (mobileImageWidth + GRID_SPACING))
);
},
[mobileImageWidth, mobileGridScrollX]
);
if (isMobile) {
return (
<div
style={{
...styles.flexColumn,
marginLeft: horizontalOuterMarginToEatOnMobile
? -horizontalOuterMarginToEatOnMobile
: 0,
marginRight: horizontalOuterMarginToEatOnMobile
? -horizontalOuterMarginToEatOnMobile
: 0,
}}
>
<Measure
bounds
onResize={contentRect => {
setMobileGridClientWidth(contentRect.bounds.width);
}}
>
{({ contentRect, measureRef }) => (
<div style={styles.flex} ref={measureRef}>
<Grid
classes={classesForGridContainer}
container
spacing={GRID_SPACING}
wrap="nowrap"
style={styles.mobileGrid}
onScroll={(event: SyntheticEvent<HTMLDivElement>) =>
setMobileGridScrollX(event.currentTarget.scrollLeft)
}
>
{imagesUrls.map((url, index) => (
<Grid
item
key={url}
style={{
scrollSnapAlign: horizontalOuterMarginToEatOnMobile
? 'center'
: 'start',
}}
>
<CardMedia>
<CorsAwareImage
src={url}
style={{
...styles.mobileImageCarouselItem,
height: mobileImageWidth / (16 / 9),
}}
alt={altTextTemplate.replace(
/{imageIndex}/g,
String(selectedImageIndex + 1)
)}
/>
</CardMedia>
</Grid>
))}
</Grid>
</div>
)}
</Measure>
<Line justifyContent="center">
<Text noMargin size="body2">
{currentlyViewedImageIndex + 1}/{imagesUrls.length}
</Text>
</Line>
</div>
);
}
return (
<ColumnStackLayout noMargin>
<CorsAwareImage
style={styles.mainImage}
src={imagesUrls[selectedImageIndex]}
alt={altTextTemplate.replace(
/{imageIndex}/g,
String(selectedImageIndex + 1)
)}
/>
<Grid
classes={classesForGridContainer}
container
spacing={GRID_SPACING}
wrap="nowrap"
style={styles.grid}
>
{imagesUrls.map((url, index) => (
<Grid
item
key={url}
tabIndex={0}
onKeyPress={(
event: SyntheticKeyboardEvent<HTMLLIElement>
): void => {
if (shouldValidate(event)) {
setSelectedImageIndex(index);
}
}}
>
<CardMedia
onClick={() => setSelectedImageIndex(index)}
style={{
...styles.carouselItem,
outline:
index === selectedImageIndex ? 'solid 1px white' : undefined,
}}
>
<CorsAwareImage
src={url}
style={styles.imageCarouselItem}
alt={altTextTemplate.replace(
/{imageIndex}/g,
(index + 1).toString()
)}
/>
</CardMedia>
</Grid>
))}
</Grid>
</ColumnStackLayout>
);
};
export default ResponsiveImagesGallery;

View File

@@ -16,6 +16,7 @@ export type SearchResult<T> = {|
type SearchOptions = {|
searchText: string,
chosenItemCategory?: string,
chosenCategory: ?ChosenCategory,
chosenFilters: Set<string>,
excludedTiers: Set<string>,
@@ -75,9 +76,11 @@ export const filterSearchResults = <
tags: Array<string>,
// Some search items can have tiers:
+tier?: string,
+category?: string,
}
>(
searchResults: ?Array<SearchResult<SearchItem>>,
chosenItemCategory: ?string,
chosenCategory: ?ChosenCategory,
chosenFilters: Set<string>,
excludedTiers: Set<string>
@@ -86,6 +89,9 @@ export const filterSearchResults = <
const startTime = performance.now();
const filteredSearchResults = searchResults
.filter(
({ item }) => !chosenItemCategory || item.category === chosenItemCategory
)
.filter(({ item: { tags } }) => {
if (!chosenCategory) return true;
@@ -136,11 +142,13 @@ export const useSearchStructuredItem = <
tags: Array<string>,
// Some search items can have tiers:
+tier?: string,
+category?: string,
}
>(
searchItemsById: ?{ [string]: SearchItem },
{
searchText,
chosenItemCategory,
chosenCategory,
chosenFilters,
excludedTiers,
@@ -228,6 +236,7 @@ export const useSearchStructuredItem = <
setSearchResults(
filterSearchResults(
orderedSearchResults,
chosenItemCategory,
chosenCategory,
chosenFilters,
excludedTiers
@@ -259,6 +268,7 @@ export const useSearchStructuredItem = <
item: result.item,
matches: tuneMatches(result, searchText),
})),
chosenItemCategory,
chosenCategory,
chosenFilters,
excludedTiers
@@ -276,6 +286,7 @@ export const useSearchStructuredItem = <
orderedSearchResults,
searchItemsById,
searchText,
chosenItemCategory,
chosenCategory,
chosenFilters,
searchApi,

View File

@@ -0,0 +1,176 @@
// @flow
import { t } from '@lingui/macro';
import * as React from 'react';
import { I18n } from '@lingui/react';
import TextField from '@material-ui/core/TextField';
import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
import { makeStyles } from '@material-ui/core';
import GDevelopThemeContext from './Theme/ThemeContext';
import Paper from '@material-ui/core/Paper';
const INVALID_VALUE = '';
const stopPropagation = event => event.stopPropagation();
const useSelectStyles = textAlign =>
makeStyles({
root: {
textAlign: textAlign || 'left',
cursor: 'default',
},
})();
const styles = {
root: {
height: 30,
display: 'flex',
justifyContent: 'space-between',
},
input: {
width: '100%',
},
searchContainer: {
position: 'relative',
margin: 'auto 8px',
width: '100%',
},
};
export type SearchBarSelectFieldInterface = {| focus: () => void |};
type ValueProps = {|
value: number | string,
// event and index should not be used, and be removed eventually
onChange?: (
event: {| target: {| value: string |} |},
index: number,
text: string // Note that even for number values, a string is returned
) => void,
|};
// We support a subset of the props supported by Material-UI v0.x SelectField
// They should be self descriptive - refer to Material UI docs otherwise.
type Props = {|
...ValueProps,
fullWidth?: boolean,
children: React.Node,
disabled?: boolean,
stopPropagationOnClick?: boolean,
style?: {
flex?: 1,
width?: 'auto',
},
margin?: 'none' | 'dense',
textAlign?: 'center',
helperMarkdownText?: ?string,
// If a hint text is specified, will be shown as an option for the empty
// value (""), disabled.
translatableHintText?: MessageDescriptor,
|};
/**
* A select field based on Material-UI select field.
* To be used with `SelectOption`.
*/
const SearchBarSelectField = React.forwardRef<
Props,
SearchBarSelectFieldInterface
>((props, ref) => {
const inputRef = React.useRef<?HTMLInputElement>(null);
const focus = React.useCallback(
() => {
if (inputRef.current) {
inputRef.current.focus();
}
},
[inputRef]
);
React.useImperativeHandle(ref, () => ({
focus,
}));
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const selectStyles = useSelectStyles(props.textAlign);
const onChange = props.onChange || undefined;
// Dig into children props to see if the current value is valid or not.
let hasValidValue = true;
const childrenValues = React.Children.map(props.children, child => {
if (child === null || !child.props) return null;
return child.props.value;
});
if (!childrenValues) {
console.error(
'SelectField has been passed no or invalid children. Only SelectOption and null are supported.'
);
} else {
hasValidValue =
childrenValues.filter(childValue => childValue === props.value).length !==
0;
}
const displayedValue = hasValidValue ? props.value : INVALID_VALUE;
return (
<I18n>
{({ i18n }) => (
<Paper
style={{
backgroundColor: gdevelopTheme.searchBar.backgroundColor,
...styles.root,
}}
square={false}
elevation={0}
>
<div style={styles.searchContainer}>
<TextField
select
color="secondary"
disabled={props.disabled}
fullWidth={props.fullWidth}
value={displayedValue}
onClick={
props.stopPropagationOnClick ? stopPropagation : undefined
}
onChange={
onChange
? event => {
onChange(event, -1, event.target.value);
}
: undefined
}
InputProps={{
style: styles.input,
disableUnderline: true,
}}
InputLabelProps={{
shrink: true,
}}
SelectProps={{
native: true,
classes: selectStyles,
}}
margin="none"
style={styles.input}
inputRef={inputRef}
>
{!hasValidValue ? (
<option value={INVALID_VALUE} disabled>
{props.translatableHintText
? i18n._(props.translatableHintText)
: i18n._(t`Choose an option`)}
</option>
) : null}
{props.children}
</TextField>
</div>
</Paper>
)}
</I18n>
);
});
export default SearchBarSelectField;

View File

@@ -83,6 +83,12 @@ export const GDevelopPlayApi = {
: 'https://api.gdevelop.io/play',
};
export const GDevelopShopApi = {
baseUrl: isDev
? 'https://api-dev.gdevelop.io/shop'
: 'https://api.gdevelop.io/shop',
};
export const GDevelopProjectApi = {
baseUrl: isDev
? 'https://api-dev.gdevelop.io/project'

View File

@@ -81,6 +81,27 @@ export type AssetPacks = {|
starterPacks: Array<AssetPack>,
|};
type PrivateAssetPackAssetType =
| 'font'
| 'audio'
| 'sprite'
| '9patch'
| 'tiled'
| 'partial'
| 'particleEmitter';
export type PrivateAssetPackContent = { [PrivateAssetPackAssetType]: number };
export type PrivateAssetPackDetails = {|
id: string,
previewImageUrls: Array<string>,
updatedAt: string,
createdAt: string,
tag: string,
longDescription: string,
content: PrivateAssetPackContent,
|};
export type AllAssets = {|
assetShortHeaders: Array<AssetShortHeader>,
filters: Filters,
@@ -112,6 +133,10 @@ export type Author = {|
export type Environment = 'staging' | 'live';
export const client = axios.create({
baseURL: GDevelopAssetApi.baseUrl,
});
/** Check if the IDE version, passed as argument, satisfy the version required by the asset. */
export const isCompatibleWithAsset = (
ideVersion: string,
@@ -128,8 +153,8 @@ export const listAllAssets = ({
}: {|
environment: Environment,
|}): Promise<AllAssets> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/asset`, {
return client
.get(`/asset`, {
params: {
environment,
},
@@ -141,9 +166,9 @@ export const listAllAssets = ({
}
return Promise.all([
axios.get(assetShortHeadersUrl).then(response => response.data),
axios.get(filtersUrl).then(response => response.data),
axios.get(assetPacksUrl).then(response => response.data),
client.get(assetShortHeadersUrl).then(response => response.data),
client.get(filtersUrl).then(response => response.data),
client.get(assetPacksUrl).then(response => response.data),
]).then(([assetShortHeaders, filters, assetPacks]) => ({
assetShortHeaders,
filters,
@@ -156,8 +181,8 @@ export const getAsset = (
assetShortHeader: AssetShortHeader,
{ environment }: {| environment: Environment |}
): Promise<Asset> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/asset/${assetShortHeader.id}`, {
return client
.get(`/asset/${assetShortHeader.id}`, {
params: {
environment,
},
@@ -168,7 +193,7 @@ export const getAsset = (
throw new Error('Unexpected response from the asset endpoint.');
}
return axios.get(assetUrl);
return client.get(assetUrl);
})
.then(response => response.data);
};
@@ -178,8 +203,8 @@ export const listAllResources = ({
}: {|
environment: Environment,
|}): Promise<AllResources> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/resource`, {
return client
.get(`/resource`, {
params: {
environment,
},
@@ -190,8 +215,8 @@ export const listAllResources = ({
throw new Error('Unexpected response from the resource endpoint.');
}
return Promise.all([
axios.get(resourcesUrl).then(response => response.data),
axios.get(filtersUrl).then(response => response.data),
client.get(resourcesUrl).then(response => response.data),
client.get(filtersUrl).then(response => response.data),
]).then(([resources, filters]) => ({
resources,
filters,
@@ -204,8 +229,8 @@ export const listAllAuthors = ({
}: {|
environment: Environment,
|}): Promise<Array<Author>> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/author`, {
return client
.get(`/author`, {
params: {
environment,
},
@@ -214,7 +239,7 @@ export const listAllAuthors = ({
.then(({ authorsUrl }) => {
if (!authorsUrl)
throw new Error('Unexpected response from author endpoint.');
return axios.get(authorsUrl);
return client.get(authorsUrl);
})
.then(response => response.data);
};
@@ -224,8 +249,8 @@ export const listAllLicenses = ({
}: {|
environment: Environment,
|}): Promise<Array<License>> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/license`, {
return client
.get(`/license`, {
params: {
environment,
},
@@ -234,11 +259,18 @@ export const listAllLicenses = ({
.then(({ licensesUrl }) => {
if (!licensesUrl)
throw new Error('Unexpected response from license endpoint.');
return axios.get(licensesUrl);
return client.get(licensesUrl);
})
.then(response => response.data);
};
export const getPrivateAssetPackDetails = async (
assetPackId: string
): Promise<PrivateAssetPackDetails> => {
const response = await client.get(`/asset-pack/${assetPackId}`);
return response.data;
};
export const isPixelArt = (
assetOrAssetShortHeader: AssetShortHeader | Asset
): boolean => {

View File

@@ -9,6 +9,7 @@ type ExtensionTier = 'community' | 'reviewed';
export type ExtensionShortHeader = {|
tier: ExtensionTier,
shortDescription: string,
authorIds: Array<string>,
authors?: Array<UserPublicProfile>,
extensionNamespace: string,
fullName: string,
@@ -18,6 +19,7 @@ export type ExtensionShortHeader = {|
url: string,
headerUrl: string,
tags: Array<string>,
category: string,
previewIconUrl: string,
eventsBasedBehaviorsCount: number,
eventsFunctionsCount: number,
@@ -47,6 +49,7 @@ export type SerializedExtension = {
export type ExtensionsRegistry = {
version: string,
allTags: Array<string>,
allCategories: Array<string>,
extensionShortHeaders: Array<ExtensionShortHeader>,
views?: {
default: {

View File

@@ -0,0 +1,26 @@
// @flow
import axios from 'axios';
import { GDevelopShopApi } from './ApiConfigs';
const client = axios.create({
baseURL: GDevelopShopApi.baseUrl,
});
export type PrivateAssetPackListingData = {|
id: string,
sellerId: string,
productType: 'ASSET_PACK',
listing: 'ASSET_PACK',
name: string,
description: string,
updatedAt: string,
createdAt: string,
thumbnailUrls: string[],
|};
export const listListedPrivateAssetPacks = async (): Promise<
Array<PrivateAssetPackListingData>
> => {
const response = await client.get('/asset-pack');
return response.data;
};

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