Compare commits

..

38 Commits

Author SHA1 Message Date
Clément Pasteau
3625acf975 Simplify tabs by removing get started page 2025-04-23 16:07:25 +02:00
Clément Pasteau
b3fa34ce3c Typo in description (#7560)
Do not show in changelog
2025-04-22 10:14:19 +02:00
github-actions[bot]
1bdb4c0369 Update extension translations [skip ci] (#7558)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-04-18 18:21:21 +02:00
github-actions[bot]
2822fab5ed Update translations [skip ci] (#7553)
Co-authored-by: AlexandreSi <32449369+AlexandreSi@users.noreply.github.com>
2025-04-18 18:20:58 +02:00
AlexandreS
658ac381fb Send language when requesting chapters when not authenticated (#7557)
Don't show in changelog
2025-04-18 17:10:19 +02:00
D8H
80cf54cb1b Fix anchored object position when the object and the camera is moved (#7556) 2025-04-18 12:50:43 +02:00
AlexandreS
2f56f6b715 Add new course about UI/UX essentials (#7555)
Keep learning with GDevelop with this new course about UI/UX essentials to make sure your game delivers the best experience to your players!
2025-04-18 11:13:04 +02:00
Clément Pasteau
9784113574 Fix allowing resource swap in resource editor on web (#7554)
Do not show in changelog
2025-04-16 17:59:54 +02:00
Clément Pasteau
e2de3bec34 Update worker url logic + consistent lights + fix using in resources list (#7551)
Do not show in changelog
2025-04-16 16:27:24 +02:00
github-actions[bot]
e837df4882 Update translations [skip ci] (#7550)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-04-15 16:13:22 +02:00
Clément Pasteau
2f44dab18b 3D models can now be previewed (#7547)
* Screenshots are taken for each 3D Model (using .glb extension) so they can be easily recognized in the editor
* Those previews are visible in the Resources tab, in the 3D Model object dialog, or when swapping/selecting from a project resource
2025-04-15 16:02:55 +02:00
Aurélien Vivet
10049ce42a Add a button to copy the Ask AI response (#7548) 2025-04-15 15:59:32 +02:00
D8H
8d9a60f819 Use a shorter label for the button to toggle parameters in drop-down list (#7549) 2025-04-15 15:58:38 +02:00
Florian Rival
8ea8c421b2 Fix TextContainerExtension description 2025-04-14 18:42:03 +02:00
github-actions[bot]
f7b026f1cc Update translations [skip ci] (#7544)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-04-14 17:27:25 +02:00
Florian Rival
623535f7fd Revert test timemout (not supported by CRA)
Don't show in changelog
2025-04-14 09:20:28 +02:00
Florian Rival
3f0ff4a9de Increase default timeout for IDE tests
Avoid failing an AppVeyor Windows build because of a timeout
2025-04-11 14:08:53 +02:00
Clément Pasteau
883ca6d535 Allow selecting resources that are already in the project when editing an object (#7541) 2025-04-11 12:21:06 +02:00
github-actions[bot]
77f56829b3 Update translations [skip ci] (#7537)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-04-08 10:36:24 +02:00
Clément Pasteau
12a842e197 Fix Piskel crashing with black screen when doing multiple undos (#7539) 2025-04-08 09:33:16 +02:00
Clément Pasteau
7145e6d049 Hide Ask AI for student accounts (#7533)
Do not show in changelog
2025-04-07 11:10:52 +02:00
László Nyiri
caa18e5fcb Fix "Never reached character" error during GDevelop.js build (#7536)
Only show in developer changelog
2025-04-07 09:20:26 +02:00
github-actions[bot]
61bf8a7cab Update translations [skip ci] (#7529)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-04-04 11:32:48 +02:00
Clément Pasteau
98b3687157 Fix cmake version for mac builds (#7531)
Do not show in changelog
2025-04-04 11:20:20 +02:00
github-actions[bot]
4b4fba2c7a Update extension translations [skip ci] (#7532)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-04-04 11:18:44 +02:00
Clément Pasteau
cedb9429c7 Improve languages dialog display (#7528)
Do not show in changelog
2025-04-02 11:28:27 +02:00
github-actions[bot]
d844b4d380 Update extension translations [skip ci] (#7527)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-04-02 10:07:12 +02:00
github-actions[bot]
e4265553e0 Update translations [skip ci] (#7526)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-04-02 09:13:15 +02:00
Florian Rival
2524b0b9d2 Update wording
Don't show in changelog
2025-04-01 18:02:37 +02:00
Florian Rival
20d16b8a57 Add minor improvements to Ask AI
Don't show in changelog
2025-04-01 13:26:14 +02:00
github-actions[bot]
67aa1ce062 Update translations [skip ci] (#7524)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-04-01 10:37:54 +02:00
Florian Rival
1759dda870 Fix formatting 2025-04-01 00:21:01 +02:00
Florian Rival
03dce1d90a Add dialog to report AI message feedback 2025-04-01 00:16:31 +02:00
Florian Rival
4e9556e948 Fix tests and improve wording of tilemaps 2025-03-31 22:53:07 +02:00
D8H
a02b8dcfe0 Add vertical alignement for BitmapText and BBText (#7523) 2025-03-31 21:00:58 +02:00
Florian Rival
416ef44ee1 Improve Ask AI tab
Don't show in changelog
2025-03-31 18:46:48 +02:00
github-actions[bot]
aa90621899 Update translations [skip ci] (#7516)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-03-31 11:14:33 +02:00
github-actions[bot]
3d3f04f63e Update extension translations [skip ci] (#7521)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-03-31 10:42:15 +02:00
260 changed files with 6878 additions and 3737 deletions

View File

@@ -19,7 +19,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTextContainerExtension(
extension
.SetExtensionInformation("TextContainerCapability",
_("Text capability"),
_("Animate objects."),
_("Allows an object to contain a text, usually shown on screen, that can be modified."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
@@ -30,7 +30,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTextContainerExtension(
"TextContainerBehavior",
_("Text capability"),
"Text",
_("Access objects text."),
_("Allows an object to contain a text, usually shown on screen, that can be modified."),
"",
"res/conditions/text24_black.png",
"TextContainerBehavior",

View File

@@ -65,7 +65,7 @@ namespace gdjs {
: behaviorData.useLegacyBottomAndRightAnchors;
}
updateFromBehaviorData(oldBehaviorData, newBehaviorData): boolean {
override updateFromBehaviorData(oldBehaviorData, newBehaviorData): boolean {
if (oldBehaviorData.leftEdgeAnchor !== newBehaviorData.leftEdgeAnchor) {
this._leftEdgeAnchor = newBehaviorData.leftEdgeAnchor;
}
@@ -96,26 +96,39 @@ namespace gdjs {
return true;
}
doStepPreEvents(instanceContainer: gdjs.RuntimeInstanceContainer) {
const objectHasMoved =
this._oldDrawableX !== this.owner.getDrawableX() ||
this._oldDrawableY !== this.owner.getDrawableY() ||
this._oldWidth !== this.owner.getWidth() ||
this._oldHeight !== this.owner.getHeight();
if (objectHasMoved) {
this._updateAnchorDistances(instanceContainer);
}
const parentHasResized =
this._parentOldMinX !== instanceContainer.getUnrotatedViewportMinX() ||
this._parentOldMinY !== instanceContainer.getUnrotatedViewportMinY() ||
this._parentOldMaxX !== instanceContainer.getUnrotatedViewportMaxX() ||
this._parentOldMaxY !== instanceContainer.getUnrotatedViewportMaxY();
if (parentHasResized) {
this._followAnchor(instanceContainer);
}
override onActivate(): void {
// This only has a side effect if the camera moved while the behavior was
// deactivated.
// The new position on the viewport is where the object should stay.
this._hasJustBeenCreated = true;
}
private _updateAnchorDistances(
override doStepPreEvents(instanceContainer: gdjs.RuntimeInstanceContainer) {
if (this._hasJustBeenCreated) {
this._initializeAnchorDistances(instanceContainer);
this._hasJustBeenCreated = false;
this._oldDrawableX = this.owner.getDrawableX();
this._oldDrawableY = this.owner.getDrawableY();
this._oldWidth = this.owner.getWidth();
this._oldHeight = this.owner.getHeight();
}
this._updateAnchorDistances(instanceContainer);
this._followAnchor(instanceContainer);
this._oldDrawableX = this.owner.getDrawableX();
this._oldDrawableY = this.owner.getDrawableY();
this._oldWidth = this.owner.getWidth();
this._oldHeight = this.owner.getHeight();
}
/**
* Evaluate the anchor distance according to the object position on the
* screen.
*
* The camera is taken into account.
*/
private _initializeAnchorDistances(
instanceContainer: gdjs.RuntimeInstanceContainer
) {
const workingPoint: FloatPoint = gdjs.staticArray(
@@ -123,38 +136,39 @@ namespace gdjs {
) as FloatPoint;
const layer = instanceContainer.getLayer(this.owner.getLayer());
let parentMinX = this._parentOldMinX;
let parentMinY = this._parentOldMinY;
let parentMaxX = this._parentOldMaxX;
let parentMaxY = this._parentOldMaxY;
if (this._hasJustBeenCreated) {
if (this._relativeToOriginalWindowSize) {
parentMinX = instanceContainer.getInitialUnrotatedViewportMinX();
parentMinY = instanceContainer.getInitialUnrotatedViewportMinY();
parentMaxX = instanceContainer.getInitialUnrotatedViewportMaxX();
parentMaxY = instanceContainer.getInitialUnrotatedViewportMaxY();
} else {
parentMinX = instanceContainer.getUnrotatedViewportMinX();
parentMinY = instanceContainer.getUnrotatedViewportMinY();
parentMaxX = instanceContainer.getUnrotatedViewportMaxX();
parentMaxY = instanceContainer.getUnrotatedViewportMaxY();
}
if (this._relativeToOriginalWindowSize) {
this._parentOldMinX =
instanceContainer.getInitialUnrotatedViewportMinX();
this._parentOldMinY =
instanceContainer.getInitialUnrotatedViewportMinY();
this._parentOldMaxX =
instanceContainer.getInitialUnrotatedViewportMaxX();
this._parentOldMaxY =
instanceContainer.getInitialUnrotatedViewportMaxY();
} else {
this._parentOldMinX = instanceContainer.getUnrotatedViewportMinX();
this._parentOldMinY = instanceContainer.getUnrotatedViewportMinY();
this._parentOldMaxX = instanceContainer.getUnrotatedViewportMaxX();
this._parentOldMaxY = instanceContainer.getUnrotatedViewportMaxY();
}
const parentMinX = this._parentOldMinX;
const parentMinY = this._parentOldMinY;
const parentMaxX = this._parentOldMaxX;
const parentMaxY = this._parentOldMaxY;
const parentCenterX = (parentMaxX + parentMinX) / 2;
const parentCenterY = (parentMaxY + parentMinY) / 2;
const parentWidth = parentMaxX - parentMinX;
const parentHeight = parentMaxY - parentMinY;
//Calculate the distances from the window's bounds.
const topLeftPixel = this._relativeToOriginalWindowSize
? [this.owner.getDrawableX(), this.owner.getDrawableY()]
: this._convertInverseCoords(
instanceContainer,
layer,
this.owner.getDrawableX(),
this.owner.getDrawableY(),
workingPoint
);
// Calculate the distances from the window's bounds.
const topLeftPixel = this._convertInverseCoords(
instanceContainer,
layer,
this.owner.getDrawableX(),
this.owner.getDrawableY(),
workingPoint
);
// Left edge
if (this._leftEdgeAnchor === HorizontalAnchor.WindowLeft) {
@@ -179,18 +193,13 @@ namespace gdjs {
}
// It's fine to reuse workingPoint as topLeftPixel is no longer used.
const bottomRightPixel = this._relativeToOriginalWindowSize
? [
this.owner.getDrawableX() + this.owner.getWidth(),
this.owner.getDrawableY() + this.owner.getHeight(),
]
: this._convertInverseCoords(
instanceContainer,
layer,
this.owner.getDrawableX() + this.owner.getWidth(),
this.owner.getDrawableY() + this.owner.getHeight(),
workingPoint
);
const bottomRightPixel = this._convertInverseCoords(
instanceContainer,
layer,
this.owner.getDrawableX() + this.owner.getWidth(),
this.owner.getDrawableY() + this.owner.getHeight(),
workingPoint
);
// Right edge
if (this._rightEdgeAnchor === HorizontalAnchor.WindowLeft) {
@@ -215,20 +224,90 @@ namespace gdjs {
} else if (this._bottomEdgeAnchor === VerticalAnchor.WindowCenter) {
this._bottomEdgeDistance = bottomRightPixel[1] - parentCenterY;
}
this._hasJustBeenCreated = false;
}
/**
* Update the anchor distances according to the object position change in
* the scene.
*
* The camera is not taken into account. Indeed, a camera scrolling should
* not shift the anchored object on screen.
*/
private _updateAnchorDistances(
instanceContainer: gdjs.RuntimeInstanceContainer
) {
if (
this._oldDrawableX !== this.owner.getDrawableX() ||
this._oldWidth !== this.owner.getWidth()
) {
const parentOldWidth = this._parentOldMaxX - this._parentOldMinX;
// Left edge
const deltaMinX = this.owner.getDrawableX() - this._oldDrawableX;
if (this._leftEdgeAnchor === HorizontalAnchor.Proportional) {
this._leftEdgeDistance += deltaMinX / parentOldWidth;
} else {
this._leftEdgeDistance += deltaMinX;
}
// Right edge
const deltaMaxX = deltaMinX + this.owner.getWidth() - this._oldWidth;
if (this._rightEdgeAnchor === HorizontalAnchor.Proportional) {
this._rightEdgeDistance += deltaMaxX / parentOldWidth;
} else {
this._rightEdgeDistance += deltaMaxX;
}
}
if (
this._oldDrawableY !== this.owner.getDrawableY() ||
this._oldHeight !== this.owner.getHeight()
) {
const parentOldHeight = this._parentOldMaxY - this._parentOldMinY;
// Top edge
const deltaMinY = this.owner.getDrawableY() - this._oldDrawableY;
if (this._topEdgeAnchor === VerticalAnchor.Proportional) {
this._topEdgeDistance += deltaMinY / parentOldHeight;
} else {
this._topEdgeDistance += deltaMinY;
}
// Bottom edge
const deltaMaxY = deltaMinY + this.owner.getHeight() - this._oldHeight;
if (this._bottomEdgeAnchor === VerticalAnchor.Proportional) {
this._bottomEdgeDistance += deltaMaxY / parentOldHeight;
} else {
this._bottomEdgeDistance += deltaMaxY;
}
}
}
/**
* Update the object position to keep the object on screen according to the
* anchor distances.
*
* The camera is taken into account.
*/
private _followAnchor(instanceContainer: gdjs.RuntimeInstanceContainer) {
let parentMinX = instanceContainer.getUnrotatedViewportMinX();
let parentMinY = instanceContainer.getUnrotatedViewportMinY();
let parentMaxX = instanceContainer.getUnrotatedViewportMaxX();
let parentMaxY = instanceContainer.getUnrotatedViewportMaxY();
if (
this._parentOldMinX === parentMinX &&
this._parentOldMinY === parentMinY &&
this._parentOldMaxX === parentMaxX &&
this._parentOldMaxY === parentMaxY
) {
return;
}
const workingPoint: FloatPoint = gdjs.staticArray(
gdjs.AnchorRuntimeBehavior.prototype.doStepPreEvents
) as FloatPoint;
const layer = instanceContainer.getLayer(this.owner.getLayer());
let parentMinX = instanceContainer.getUnrotatedViewportMinX();
let parentMinY = instanceContainer.getUnrotatedViewportMinY();
let parentMaxX = instanceContainer.getUnrotatedViewportMaxX();
let parentMaxY = instanceContainer.getUnrotatedViewportMaxY();
const parentCenterX = (parentMaxX + parentMinX) / 2;
const parentCenterY = (parentMaxY + parentMinY) / 2;
const parentWidth = parentMaxX - parentMinX;
@@ -388,11 +467,6 @@ namespace gdjs {
}
}
}
this._oldDrawableX = this.owner.getDrawableX();
this._oldDrawableY = this.owner.getDrawableY();
this._oldWidth = this.owner.getWidth();
this._oldHeight = this.owner.getHeight();
this._parentOldMinX = instanceContainer.getUnrotatedViewportMinX();
this._parentOldMinY = instanceContainer.getUnrotatedViewportMinY();
this._parentOldMaxX = instanceContainer.getUnrotatedViewportMaxX();

View File

@@ -0,0 +1,45 @@
// @ts-check
describe.only('gdjs.AnchorRuntimeBehavior', () => {
it('can fill a custom object with an child', async () => {
const runtimeGame = await gdjs.getPixiRuntimeGameWithAssets();
const runtimeScene = new gdjs.TestRuntimeScene(runtimeGame);
// The corresponding event-based object declaration is done by
// getPixiRuntimeGame.
const customObject = new gdjs.CustomRuntimeObject2D(runtimeScene, {
name: 'MyCustomObject',
type: 'MyExtension::MyLayoutedEventsBasedObject',
variables: [],
behaviors: [],
effects: [],
content: {},
childrenContent: {},
});
runtimeScene.addObject(customObject);
customObject.setPosition(500, 250);
const childObjects = customObject
.getChildrenContainer()
.getObjects('MySprite');
if (!childObjects || childObjects.length === 0) {
throw new Error("Can't get child objects.");
}
const childObject = childObjects[0];
runtimeScene.renderAndStep(1000 / 60);
// The child object keeps its initial location.
expect(childObject.getX()).to.equal(0);
expect(childObject.getY()).to.equal(0);
expect(childObject.getWidth()).to.equal(64);
expect(childObject.getHeight()).to.equal(64);
customObject.setWidth(2000);
customObject.setHeight(3000);
runtimeScene.renderAndStep(1000 / 60);
expect(childObject.getX()).to.equal(0);
expect(childObject.getY()).to.equal(0);
expect(childObject.getWidth()).to.equal(2000);
expect(childObject.getHeight()).to.equal(3000);
});
});

View File

@@ -1,39 +1,49 @@
// @ts-check
describe('gdjs.AnchorRuntimeBehavior', function () {
const runtimeGame = gdjs.getPixiRuntimeGame({
propertiesOverrides: { windowHeight: 1000, windowWidth: 1000 },
});
describe('gdjs.AnchorRuntimeBehavior', () => {
const anchorBehaviorName = 'Anchor';
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
runtimeScene.loadFromScene({
sceneData: {
layers: [
{
name: '',
visibility: true,
cameras: [],
effects: [],
ambientLightColorR: 127,
ambientLightColorB: 127,
ambientLightColorG: 127,
isLightingLayer: false,
followBaseLayerCamera: false,
},
],
variables: [],
r: 0,
v: 0,
b: 0,
mangledName: 'Scene1',
name: 'Scene1',
stopSoundsOnStartup: false,
title: '',
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
},
usedExtensionsWithVariablesData: [],
/** @type {gdjs.RuntimeGame} */
let runtimeGame;
/** @type {gdjs.RuntimeScene} */
let runtimeScene;
/** @type {gdjs.RuntimeLayer} */
let layer;
beforeEach(() => {
runtimeGame = gdjs.getPixiRuntimeGame({
propertiesOverrides: { windowHeight: 1000, windowWidth: 1000 },
});
runtimeScene = new gdjs.RuntimeScene(runtimeGame);
runtimeScene.loadFromScene({
sceneData: {
layers: [
{
name: '',
visibility: true,
cameras: [],
effects: [],
ambientLightColorR: 127,
ambientLightColorB: 127,
ambientLightColorG: 127,
isLightingLayer: false,
followBaseLayerCamera: false,
},
],
variables: [],
r: 0,
v: 0,
b: 0,
mangledName: 'Scene1',
name: 'Scene1',
stopSoundsOnStartup: false,
title: '',
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
},
usedExtensionsWithVariablesData: [],
});
layer = runtimeScene.getLayer('');
});
const setGameResolutionSizeAndStep = (width, height) => {
@@ -43,6 +53,11 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
runtimeScene.renderAndStep(1000 / 60);
};
const setCamera = (x, y) => {
layer.setCameraX(x);
layer.setCameraY(y);
};
function createObject(behaviorProperties) {
const object = new gdjs.TestRuntimeObject(runtimeScene, {
name: 'obj1',
@@ -114,10 +129,50 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
return object;
};
describe('(anchor horizontal edge)', function () {
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window left (fixed)`, function () {
const object = createObject({ [objectEdge]: 1 });
describe('(anchor horizontal edge)', () => {
describe('(basic)', () => {
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window left (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window right (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1000);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the right and left edge of an object (fixed)', () => {
const object = createObject({ leftEdgeAnchor: 1, rightEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
@@ -125,25 +180,11 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
expect(object.getWidth()).to.equal(1010);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window right (fixed)`, function () {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window center (fixed)`, function () {
const object = createObject({ [objectEdge]: 4 });
it('anchors the left edge of an object (proportional)', () => {
const object = createObject({ leftEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
@@ -154,36 +195,267 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
expect(object.getWidth()).to.equal(10);
});
});
describe('(moving object)', () => {
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window left (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
it('anchors the right and left edge of object (fixed)', function () {
const object = createObject({ leftEdgeAnchor: 1, rightEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window right (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(1010);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1600);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1100);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the right and left edge of an object (fixed)', () => {
const object = createObject({ leftEdgeAnchor: 1, rightEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(1010);
});
it('anchors the left edge of an object (proportional)', () => {
const object = createObject({ leftEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1200);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
describe('(moving camera)', () => {
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window left (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
it('anchors the left edge of object (proportional)', function () {
const object = createObject({ leftEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(800);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window right (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
expect(object.getX()).to.equal(1000);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1800);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1300);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the right and left edge of an object (fixed)', () => {
const object = createObject({ leftEdgeAnchor: 1, rightEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(800);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(1010);
});
it('anchors the left edge of an object (proportional)', () => {
const object = createObject({ leftEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1300);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
describe('(moving object and camera)', () => {
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window left (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(900);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window right (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1900);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
['rightEdgeAnchor', 'leftEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of an object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1400);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the right and left edge of an object (fixed)', () => {
const object = createObject({ leftEdgeAnchor: 1, rightEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(900);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(1010);
});
it('anchors the left edge of an object (proportional)', () => {
const object = createObject({ leftEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1500);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
});
describe('(anchor vertical edge)', function () {
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window top (fixed)`, function () {
const object = createObject({ [objectEdge]: 1 });
describe('(anchor vertical edge)', () => {
describe('(basic)', () => {
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window top (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window bottom (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1500);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1000);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the top and bottom edge of object (fixed)', () => {
const object = createObject({ topEdgeAnchor: 1, bottomEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
@@ -191,25 +463,11 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(500);
expect(object.getWidth()).to.equal(10);
expect(object.getHeight()).to.equal(1010);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window bottom (fixed)`, function () {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1500);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window center (fixed)`, function () {
const object = createObject({ [objectEdge]: 4 });
it('anchors the top edge of object (proportional)', () => {
const object = createObject({ topEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
@@ -220,71 +478,262 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
expect(object.getWidth()).to.equal(10);
});
});
describe('(moving object)', () => {
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window top (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
it('anchors the top and bottom edge of object (fixed)', function () {
const object = createObject({ topEdgeAnchor: 1, bottomEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(500);
expect(object.getHeight()).to.equal(1010);
});
it('anchors the top edge of object (proportional)', function () {
const object = createObject({ topEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1000);
expect(object.getWidth()).to.equal(10);
});
it('can fill the screen with an object (with custom origin)', function () {
setGameResolutionSizeAndStep(1000, 500);
const object = createSpriteWithOriginAtCenter({
leftEdgeAnchor: 1,
topEdgeAnchor: 1,
rightEdgeAnchor: 2,
bottomEdgeAnchor: 2,
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(700);
expect(object.getWidth()).to.equal(10);
});
});
object.setCustomWidthAndHeight(1000, 500);
object.setPosition(500, 250);
runtimeScene.renderAndStep(1000 / 60);
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window bottom (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 3000);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1000);
expect(object.getY()).to.equal(1500);
expect(object.getWidth()).to.equal(2000);
expect(object.getHeight()).to.equal(3000);
});
it('can fill the screen with an object using proportional anchors (with custom origin)', () => {
setGameResolutionSizeAndStep(1000, 500);
const object = createSpriteWithOriginAtCenter({
leftEdgeAnchor: 3,
topEdgeAnchor: 3,
rightEdgeAnchor: 3,
bottomEdgeAnchor: 3,
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1700);
expect(object.getWidth()).to.equal(10);
});
});
object.setCustomWidthAndHeight(1000, 500);
object.setPosition(500, 250);
runtimeScene.renderAndStep(1000 / 60);
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 3000);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(1000);
expect(object.getY()).to.equal(1500);
expect(object.getWidth()).to.equal(2000);
expect(object.getHeight()).to.equal(3000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1200);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the top and bottom edge of object (fixed)', () => {
const object = createObject({ topEdgeAnchor: 1, bottomEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(700);
expect(object.getHeight()).to.equal(1010);
});
it('anchors the top edge of object (proportional)', () => {
const object = createObject({ topEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1400);
expect(object.getWidth()).to.equal(10);
});
});
describe('(moving camera)', () => {
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window top (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(900);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window bottom (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1900);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1400);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the top and bottom edge of object (fixed)', () => {
const object = createObject({ topEdgeAnchor: 1, bottomEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(900);
expect(object.getHeight()).to.equal(1010);
});
it('anchors the top edge of object (proportional)', () => {
const object = createObject({ topEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(500);
expect(object.getY()).to.equal(1400);
expect(object.getWidth()).to.equal(10);
});
});
describe('(moving object and camera)', () => {
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window top (fixed)`, () => {
const object = createObject({ [objectEdge]: 1 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1100);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window bottom (fixed)`, () => {
const object = createObject({ [objectEdge]: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(2100);
expect(object.getWidth()).to.equal(10);
});
});
['topEdgeAnchor', 'bottomEdgeAnchor'].forEach((objectEdge) => {
it(`anchors the ${objectEdge} edge of object to window center (fixed)`, () => {
const object = createObject({ [objectEdge]: 4 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1600);
expect(object.getWidth()).to.equal(10);
});
});
it('anchors the top and bottom edge of object (fixed)', () => {
const object = createObject({ topEdgeAnchor: 1, bottomEdgeAnchor: 2 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1100);
expect(object.getHeight()).to.equal(1010);
});
it('anchors the top edge of object (proportional)', () => {
const object = createObject({ topEdgeAnchor: 3 });
object.setPosition(500, 500);
runtimeScene.renderAndStep(1000 / 60);
object.setPosition(600, 700);
setCamera(1300, 1400);
setGameResolutionSizeAndStep(2000, 2000);
expect(object.getX()).to.equal(600);
expect(object.getY()).to.equal(1800);
expect(object.getWidth()).to.equal(10);
});
});
});
it('can fill the screen with an object (with custom origin)', () => {
setGameResolutionSizeAndStep(1000, 500);
const object = createSpriteWithOriginAtCenter({
leftEdgeAnchor: 1,
topEdgeAnchor: 1,
rightEdgeAnchor: 2,
bottomEdgeAnchor: 2,
});
object.setCustomWidthAndHeight(1000, 500);
object.setPosition(500, 250);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 3000);
expect(object.getX()).to.equal(1000);
expect(object.getY()).to.equal(1500);
expect(object.getWidth()).to.equal(2000);
expect(object.getHeight()).to.equal(3000);
});
it('can fill the screen with an object using proportional anchors (with custom origin)', () => {
setGameResolutionSizeAndStep(1000, 500);
const object = createSpriteWithOriginAtCenter({
leftEdgeAnchor: 3,
topEdgeAnchor: 3,
rightEdgeAnchor: 3,
bottomEdgeAnchor: 3,
});
object.setCustomWidthAndHeight(1000, 500);
object.setPosition(500, 250);
runtimeScene.renderAndStep(1000 / 60);
setGameResolutionSizeAndStep(2000, 3000);
expect(object.getX()).to.equal(1000);
expect(object.getY()).to.equal(1500);
expect(object.getWidth()).to.equal(2000);
expect(object.getHeight()).to.equal(3000);
});
});

View File

@@ -81,6 +81,19 @@ module.exports = {
.setLabel(_('Base alignment'))
.setGroup(_('Appearance'));
if (!objectContent.verticalTextAlignment) {
objectContent.verticalTextAlignment = 'top';
}
objectProperties
.getOrCreate('verticalTextAlignment')
.setValue(objectContent.verticalTextAlignment)
.setType('choice')
.addExtraInfo('top')
.addExtraInfo('center')
.addExtraInfo('bottom')
.setLabel(_('Vertical alignment'))
.setGroup(_('Appearance'));
objectProperties
.getOrCreate('fontFamily')
.setValue(objectContent.fontFamily)
@@ -89,13 +102,6 @@ module.exports = {
.setLabel(_('Font'))
.setGroup(_('Font'));
objectProperties
.getOrCreate('wordWrap')
.setValue(objectContent.wordWrap ? 'true' : 'false')
.setType('boolean')
.setLabel(_('Word wrapping'))
.setGroup(_('Appearance'));
objectProperties
.getOrCreate('visible')
.setValue(objectContent.visible ? 'true' : 'false')
@@ -113,7 +119,7 @@ module.exports = {
color: '0;0;0',
fontFamily: 'Arial',
align: 'left',
wordWrap: true,
verticalTextAlignment: 'top',
};
objectBBText.updateInitialInstanceProperty = function (
@@ -371,19 +377,6 @@ module.exports = {
expressionLabel: _('Get the text alignment'),
expressionDescription: _('Get the text alignment'),
},
{
functionName: 'WordWrap',
iconPath: 'res/actions/scaleWidth24_black.png',
type: 'boolean',
instructionLabel: _('Word wrap'),
paramLabel: _('Word wrap'),
conditionDescription: _('Check if word wrap is enabled.'),
conditionSentence: _('Word wrap is enabled'),
actionDescription: _('Set word wrap'),
actionSentence: _('Activate word wrap for _PARAM0_: _PARAM1_'),
expressionLabel: '',
expressionDescription: '',
},
{
functionName: 'WrappingWidth',
iconPath: 'res/actions/scaleWidth24_black.png',
@@ -405,6 +398,35 @@ module.exports = {
addSettersAndGettersToObject(object, setterAndGetterProperties, 'BBText');
object
.addCondition(
'IsWordWrap',
_('Word wrapping'),
_('Check if word wrapping is enabled.'),
_('_PARAM0_ word wrapping is enabled'),
'',
'res/conditions/wordWrap24_black.png',
'res/conditions/wordWrap_black.png'
)
.addParameter('object', 'BBText', 'BBText', false)
.getCodeExtraInformation()
.setFunctionName('isWrapping');
object
.addAction(
'SetWordWrap',
_('Word wrapping'),
_('De/activate word wrapping.'),
_('Activate word wrapping of _PARAM0_: _PARAM1_'),
'',
'res/actions/wordWrap24_black.png',
'res/actions/wordWrap_black.png'
)
.addParameter('object', 'BBText', 'BBText', false)
.addParameter('yesorno', _('Activate word wrapping'), '', false)
.getCodeExtraInformation()
.setFunctionName('setWrapping');
object
.addAction(
`SetFontFamily2`,
@@ -502,7 +524,6 @@ module.exports = {
fontSize: '24px',
fill: '#cccccc',
tagStyle: 'bbcode',
wordWrap: true,
wordWrapWidth: 250, // This value is the default wrapping width of the runtime object.
align: 'left',
},
@@ -574,11 +595,18 @@ module.exports = {
});
}
const wordWrap = object.content.wordWrap;
const wordWrap = this._instance.hasCustomSize();
if (wordWrap !== this._pixiObject._style.wordWrap) {
this._pixiObject._style.wordWrap = wordWrap;
this._pixiObject.dirty = true;
}
if (this._instance.hasCustomSize()) {
const customWidth = this.getCustomWidth();
if (this._pixiObject._style.wordWrapWidth !== customWidth) {
this._pixiObject._style.wordWrapWidth = customWidth;
this._pixiObject.dirty = true;
}
}
const align = object.content.align;
if (align !== this._pixiObject._style.align) {
@@ -586,25 +614,42 @@ module.exports = {
this._pixiObject.dirty = true;
}
this._pixiObject.position.x =
this._instance.getX() + this._pixiObject.width / 2;
if (this._instance.hasCustomSize()) {
const alignmentX =
object.content.align === 'right'
? 1
: object.content.align === 'center'
? 0.5
: 0;
const width = this.getCustomWidth();
// A vector from the custom size center to the renderer center.
const centerToCenterX =
(width - this._pixiObject.width) * (alignmentX - 0.5);
this._pixiObject.position.x = this._instance.getX() + width / 2;
this._pixiObject.anchor.x =
0.5 - centerToCenterX / this._pixiObject.width;
} else {
this._pixiObject.position.x =
this._instance.getX() + this._pixiObject.width / 2;
this._pixiObject.anchor.x = 0.5;
}
const alignmentY =
object.content.verticalTextAlignment === 'bottom'
? 1
: object.content.verticalTextAlignment === 'center'
? 0.5
: 0;
this._pixiObject.position.y =
this._instance.getY() + this._pixiObject.height / 2;
this._instance.getY() + this._pixiObject.height * (0.5 - alignmentY);
this._pixiObject.anchor.y = 0.5;
this._pixiObject.rotation = RenderedInstance.toRad(
this._instance.getAngle()
);
if (this._instance.hasCustomSize() && this._pixiObject) {
const customWidth = this.getCustomWidth();
if (
this._pixiObject &&
this._pixiObject._style.wordWrapWidth !== customWidth
) {
this._pixiObject._style.wordWrapWidth = customWidth;
this._pixiObject.dirty = true;
}
}
// Do not hide completely an object so it can still be manipulated
const alphaForDisplay = Math.max(
this._instance.getOpacity() / 255,
@@ -626,6 +671,19 @@ module.exports = {
getDefaultHeight() {
return this._pixiObject.height;
}
getOriginY() {
const object = gd.castObject(
this._associatedObjectConfiguration,
gd.ObjectJsImplementation
);
const height = this.getHeight();
return object.content.verticalTextAlignment === 'bottom'
? height
: object.content.verticalTextAlignment === 'center'
? height / 2
: 0;
}
}
objectsRenderingService.registerInstanceRenderer(

View File

@@ -29,9 +29,9 @@ namespace gdjs {
runtimeObject._color[2]
),
tagStyle: 'bbcode',
wordWrap: runtimeObject._wordWrap,
wordWrap: runtimeObject._wrapping,
wordWrapWidth: runtimeObject._wrappingWidth,
align: runtimeObject._align as PIXI.TextStyleAlign | undefined,
align: runtimeObject._textAlign as PIXI.TextStyleAlign | undefined,
},
});
instanceContainer
@@ -39,10 +39,7 @@ namespace gdjs {
.getRenderer()
.addRendererObject(this._pixiObject, runtimeObject.getZOrder());
// Set the anchor in the center, so that the object rotates around
// its center
this._pixiObject.anchor.x = 0.5;
this._pixiObject.anchor.y = 0.5;
this.updateAlignment();
this.updateText();
this.updatePosition();
this.updateAngle();
@@ -55,7 +52,7 @@ namespace gdjs {
updateWordWrap(): void {
//@ts-ignore Private member usage.
this._pixiObject._style.wordWrap = this._object._wordWrap;
this._pixiObject._style.wordWrap = this._object._wrapping;
this._pixiObject.dirty = true;
this.updatePosition();
}
@@ -84,7 +81,7 @@ namespace gdjs {
updateAlignment(): void {
//@ts-ignore Private member usage.
this._pixiObject._style.align = this._object._align;
this._pixiObject._style.align = this._object._textAlign;
this._pixiObject.dirty = true;
}
@@ -106,9 +103,38 @@ namespace gdjs {
}
updatePosition(): void {
this._pixiObject.position.x = this._object.x + this._pixiObject.width / 2;
if (this._object.isWrapping()) {
const alignmentX =
this._object._textAlign === 'right'
? 1
: this._object._textAlign === 'center'
? 0.5
: 0;
const width = this._object.getWrappingWidth();
// A vector from the custom size center to the renderer center.
const centerToCenterX =
(width - this._pixiObject.width) * (alignmentX - 0.5);
this._pixiObject.position.x = this._object.x + width / 2;
this._pixiObject.anchor.x =
0.5 - centerToCenterX / this._pixiObject.width;
} else {
this._pixiObject.position.x =
this._object.x + this._pixiObject.width / 2;
this._pixiObject.anchor.x = 0.5;
}
const alignmentY =
this._object._verticalTextAlignment === 'bottom'
? 1
: this._object._verticalTextAlignment === 'center'
? 0.5
: 0;
this._pixiObject.position.y =
this._object.y + this._pixiObject.height / 2;
this._object.y + this._pixiObject.height * (0.5 - alignmentY);
this._pixiObject.anchor.y = 0.5;
}
updateAngle(): void {

View File

@@ -19,6 +19,7 @@ namespace gdjs {
wordWrap: boolean;
/** Alignment of the text: "left", "center" or "right" */
align: 'left' | 'center' | 'right';
verticalTextAlignment: 'top' | 'center' | 'bottom';
};
};
export type BBTextObjectData = ObjectData & BBTextObjectDataType;
@@ -32,6 +33,7 @@ namespace gdjs {
wwrap: boolean;
wwidth: float;
align: string;
vta: string;
hidden: boolean;
};
@@ -52,13 +54,14 @@ namespace gdjs {
/** color in format [r, g, b], where each component is in the range [0, 255] */
_color: integer[];
_fontFamily: string;
_fontSize: number;
_fontSize: float;
_wordWrap: boolean;
_wrapping: boolean = false;
_wrappingWidth: float = 250;
// This value is the default wrapping width of the runtime object.
_align: string;
_textAlign: string;
_verticalTextAlignment: string;
_renderer: gdjs.BBTextRuntimeObjectRenderer;
// While this should rather be exposed as a property for all objects, honor the "visible"
@@ -81,24 +84,26 @@ namespace gdjs {
this._fontFamily = objectData.content.fontFamily;
// @ts-ignore - parseFloat should not be required, but GDevelop 5.0 beta 92 and below were storing it as a string.
this._fontSize = parseFloat(objectData.content.fontSize);
this._wordWrap = objectData.content.wordWrap;
this._align = objectData.content.align;
this._textAlign = objectData.content.align;
this._verticalTextAlignment =
objectData.content.verticalTextAlignment || 'top';
this.hidden = !objectData.content.visible;
this._renderer = new gdjs.BBTextRuntimeObjectRenderer(
this,
instanceContainer
);
this.hidden = !objectData.content.visible;
// *ALWAYS* call `this.onCreated()` at the very end of your object constructor.
this.onCreated();
}
getRendererObject() {
override getRendererObject() {
return this._renderer.getRendererObject();
}
// @ts-ignore
updateFromObjectData(
override updateFromObjectData(
oldObjectData: BBTextObjectDataType,
newObjectData: BBTextObjectDataType
): boolean {
@@ -124,15 +129,23 @@ namespace gdjs {
this.setFontSize(newObjectData.content.fontSize);
}
if (oldObjectData.content.wordWrap !== newObjectData.content.wordWrap) {
this.setWordWrap(newObjectData.content.wordWrap);
this.setWrapping(newObjectData.content.wordWrap);
}
if (oldObjectData.content.align !== newObjectData.content.align) {
this.setAlignment(newObjectData.content.align);
this.setTextAlignment(newObjectData.content.align);
}
if (
oldObjectData.content.verticalTextAlignment !==
newObjectData.content.verticalTextAlignment
) {
this.setVerticalTextAlignment(
newObjectData.content.verticalTextAlignment
);
}
return true;
}
getNetworkSyncData(): BBTextObjectNetworkSyncData {
override getNetworkSyncData(): BBTextObjectNetworkSyncData {
return {
...super.getNetworkSyncData(),
text: this._text,
@@ -140,14 +153,15 @@ namespace gdjs {
c: this._color,
ff: this._fontFamily,
fs: this._fontSize,
wwrap: this._wordWrap,
wwrap: this._wrapping,
wwidth: this._wrappingWidth,
align: this._align,
align: this._textAlign,
vta: this._verticalTextAlignment,
hidden: this.hidden,
};
}
updateFromNetworkSyncData(
override updateFromNetworkSyncData(
networkSyncData: BBTextObjectNetworkSyncData
): void {
super.updateFromNetworkSyncData(networkSyncData);
@@ -167,26 +181,29 @@ namespace gdjs {
if (this._fontSize !== undefined) {
this.setFontSize(networkSyncData.fs);
}
if (this._wordWrap !== undefined) {
this.setWordWrap(networkSyncData.wwrap);
if (this._wrapping !== undefined) {
this.setWrapping(networkSyncData.wwrap);
}
if (this._wrappingWidth !== undefined) {
this.setWrappingWidth(networkSyncData.wwidth);
}
if (this._align !== undefined) {
this.setAlignment(networkSyncData.align);
if (this._textAlign !== undefined) {
this.setTextAlignment(networkSyncData.align);
}
if (this._verticalTextAlignment !== undefined) {
this.setVerticalTextAlignment(networkSyncData.vta);
}
if (this.hidden !== undefined) {
this.hide(networkSyncData.hidden);
}
}
/**
* Initialize the extra parameters that could be set for an instance.
*/
extraInitializationFromInitialInstance(initialInstanceData: InstanceData) {
override extraInitializationFromInitialInstance(
initialInstanceData: InstanceData
) {
if (initialInstanceData.customSize) {
this.setWrappingWidth(initialInstanceData.width);
this.setWrapping(true);
} else {
this.setWrappingWidth(
// This value is the default wrapping width of the runtime object.
@@ -198,7 +215,7 @@ namespace gdjs {
}
}
onDestroyed(): void {
override onDestroyed(): void {
super.onDestroyed();
this._renderer.destroy();
}
@@ -206,7 +223,7 @@ namespace gdjs {
/**
* Set the markup text to display.
*/
setBBText(text): void {
setBBText(text: string): void {
this._text = text;
this._renderer.updateText();
this.invalidateHitboxes();
@@ -219,7 +236,7 @@ namespace gdjs {
return this._text;
}
setColor(rgbColorString): void {
setColor(rgbColorString: string): void {
this._color = gdjs.rgbOrHexToRGBColor(rgbColorString);
this._renderer.updateColor();
}
@@ -232,7 +249,7 @@ namespace gdjs {
return this._color[0] + ';' + this._color[1] + ';' + this._color[2];
}
setFontSize(fontSize): void {
setFontSize(fontSize: float): void {
this._fontSize = fontSize;
this._renderer.updateFontSize();
}
@@ -241,47 +258,66 @@ namespace gdjs {
return this._fontSize;
}
setFontFamily(fontFamily): void {
setFontFamily(fontFamily: string): void {
this._fontFamily = fontFamily;
this._renderer.updateFontFamily();
}
getFontFamily() {
getFontFamily(): string {
return this._fontFamily;
}
setAlignment(align): void {
this._align = align;
/**
* @deprecated Use `setTextAlignment` instead
*/
setAlignment(align: string): void {
this.setTextAlignment(align);
}
setTextAlignment(align: string): void {
this._textAlign = align;
this._renderer.updateAlignment();
}
getAlignment() {
return this._align;
/**
* @deprecated Use `getTextAlignment` instead
*/
getAlignment(): string {
return this.getTextAlignment();
}
getTextAlignment(): string {
return this._textAlign;
}
/**
* Set object position on X axis.
* @param x The new position X of the object.
* Set the text alignment on Y axis for multiline text objects.
* @param alignment The text alignment.
*/
setX(x: float): void {
setVerticalTextAlignment(alignment: string): void {
this._verticalTextAlignment = alignment;
this._renderer.updatePosition();
}
/**
* Get the text alignment on Y axis of text object.
* @return The text alignment.
*/
getVerticalTextAlignment(): string {
return this._verticalTextAlignment;
}
override setX(x: float): void {
super.setX(x);
this._renderer.updatePosition();
}
/**
* Set object position on Y axis.
* @param y The new position Y of the object.
*/
setY(y: float): void {
override setY(y: float): void {
super.setY(y);
this._renderer.updatePosition();
}
/**
* Set the angle of the object.
* @param angle The new angle of the object.
*/
setAngle(angle: float): void {
override setAngle(angle: float): void {
super.setAngle(angle);
this._renderer.updateAngle();
}
@@ -327,31 +363,36 @@ namespace gdjs {
return this._wrappingWidth;
}
setWordWrap(wordWrap: boolean): void {
if (this._wordWrap === wordWrap) return;
setWrapping(wordWrap: boolean): void {
if (this._wrapping === wordWrap) return;
this._wordWrap = wordWrap;
this._wrapping = wordWrap;
this._renderer.updateWordWrap();
this.invalidateHitboxes();
}
getWordWrap() {
return this._wordWrap;
isWrapping() {
return this._wrapping;
}
/**
* Get the width of the object.
*/
getWidth(): float {
override getWidth(): float {
return this._renderer.getWidth();
}
/**
* Get the height of the object.
*/
getHeight(): float {
override getHeight(): float {
return this._renderer.getHeight();
}
override getDrawableY(): float {
return (
this.getY() -
(this._verticalTextAlignment === 'center'
? this.getHeight() / 2
: this._verticalTextAlignment === 'bottom'
? this.getHeight()
: 0)
);
}
}
// @ts-ignore
gdjs.registerObject('BBText::BBText', gdjs.BBTextRuntimeObject);

View File

@@ -67,6 +67,19 @@ module.exports = {
.setLabel(_('Alignment'))
.setGroup(_('Appearance'));
if (!objectContent.verticalTextAlignment) {
objectContent.verticalTextAlignment = 'top';
}
objectProperties
.getOrCreate('verticalTextAlignment')
.setValue(objectContent.verticalTextAlignment)
.setType('choice')
.addExtraInfo('top')
.addExtraInfo('center')
.addExtraInfo('bottom')
.setLabel(_('Vertical alignment'))
.setGroup(_('Appearance'));
objectProperties
.getOrCreate('bitmapFontResourceName')
.setValue(objectContent.bitmapFontResourceName)
@@ -97,13 +110,6 @@ module.exports = {
.setLabel(_('Font tint'))
.setGroup(_('Font'));
objectProperties
.getOrCreate('wordWrap')
.setValue(objectContent.wordWrap ? 'true' : 'false')
.setType('boolean')
.setLabel(_('Word wrapping'))
.setGroup(_('Appearance'));
return objectProperties;
};
bitmapTextObject.content = {
@@ -115,7 +121,7 @@ module.exports = {
bitmapFontResourceName: '',
textureAtlasResourceName: '',
align: 'left',
wordWrap: true,
verticalTextAlignment: 'top',
};
bitmapTextObject.updateInitialInstanceProperty = function (
@@ -341,7 +347,7 @@ module.exports = {
_('Alignment ("left", "right" or "center")')
)
)
.setFunctionName('getAlignment');
.setFunctionName('getTextAlignment');
object
.addAction(
@@ -361,36 +367,36 @@ module.exports = {
false
)
.getCodeExtraInformation()
.setFunctionName('setAlignment');
.setFunctionName('setTextAlignment');
object
.addCondition(
'WordWrap',
_('Word wrap'),
_('Check if word wrap is enabled.'),
_('_PARAM0_ word wrap is enabled'),
_('Word wrapping'),
_('Check if word wrapping is enabled.'),
_('_PARAM0_ word wrapping is enabled'),
'',
'res/conditions/wordWrap24_black.png',
'res/conditions/wordWrap_black.png'
)
.addParameter('object', _('Bitmap text'), 'BitmapTextObject', false)
.getCodeExtraInformation()
.setFunctionName('getWordWrap');
.setFunctionName('isWrapping');
object
.addAction(
'SetWordWrap',
_('Word wrap'),
_('Word wrapping'),
_('De/activate word wrapping.'),
_('Activate word wrap of _PARAM0_: _PARAM1_'),
_('Activate word wrapping of _PARAM0_: _PARAM1_'),
'',
'res/actions/wordWrap24_black.png',
'res/actions/wordWrap_black.png'
)
.addParameter('object', _('Bitmap text'), 'BitmapTextObject', false)
.addParameter('yesorno', _('Activate word wrap'), '', false)
.addParameter('yesorno', _('Activate word wrapping'), '', false)
.getCodeExtraInformation()
.setFunctionName('setWordWrap');
.setFunctionName('setWrapping');
object
.addExpressionAndConditionAndAction(
@@ -704,20 +710,46 @@ module.exports = {
}
// Set up the wrapping width if enabled.
const wordWrap = object.content.wordWrap;
if (wordWrap && this._instance.hasCustomSize()) {
this._pixiObject.maxWidth =
this.getCustomWidth() / this._pixiObject.scale.x;
this._pixiObject.dirty = true;
} else {
this._pixiObject.maxWidth = 0;
const oldMaxWidth = this._pixiObject.maxWidth;
this._pixiObject.maxWidth = this._instance.hasCustomSize()
? this.getCustomWidth() / this._pixiObject.scale.x
: 0;
if (oldMaxWidth !== this._pixiObject.maxWidth) {
this._pixiObject.dirty = true;
}
this._pixiObject.position.x =
this._instance.getX() + (this._pixiObject.textWidth * scale) / 2;
if (this._instance.hasCustomSize()) {
const alignmentX =
object.content.align === 'right'
? 1
: object.content.align === 'center'
? 0.5
: 0;
const width = this.getCustomWidth();
// A vector from the custom size center to the renderer center.
const centerToCenterX =
(width - this._pixiObject.width) * (alignmentX - 0.5);
this._pixiObject.position.x = this._instance.getX() + width / 2;
this._pixiObject.anchor.x =
0.5 - centerToCenterX / this._pixiObject.width;
} else {
this._pixiObject.position.x =
this._instance.getX() + this._pixiObject.width / 2;
this._pixiObject.anchor.x = 0.5;
}
const alignmentY =
object.content.verticalTextAlignment === 'bottom'
? 1
: object.content.verticalTextAlignment === 'center'
? 0.5
: 0;
this._pixiObject.position.y =
this._instance.getY() + (this._pixiObject.textHeight * scale) / 2;
this._instance.getY() + this._pixiObject.height * (0.5 - alignmentY);
this._pixiObject.anchor.y = 0.5;
this._pixiObject.rotation = RenderedInstance.toRad(
this._instance.getAngle()
);
@@ -738,19 +770,26 @@ module.exports = {
releaseBitmapFont(fontName);
}
/**
* Return the width of the instance, when it's not resized.
*/
getDefaultWidth() {
return this._pixiObject.width;
}
/**
* Return the height of the instance, when it's not resized.
*/
getDefaultHeight() {
return this._pixiObject.height;
}
getOriginY() {
const object = gd.castObject(
this._associatedObjectConfiguration,
gd.ObjectJsImplementation
);
const height = this.getHeight();
return object.content.verticalTextAlignment === 'bottom'
? height
: object.content.verticalTextAlignment === 'center'
? height / 2
: 0;
}
}
objectsRenderingService.registerInstanceRenderer(

View File

@@ -35,13 +35,6 @@ namespace gdjs {
.getRenderer()
.addRendererObject(this._pixiObject, runtimeObject.getZOrder());
// Set the anchor in the center, so that the object rotates around
// its center.
// @ts-ignore
this._pixiObject.anchor.x = 0.5;
// @ts-ignore
this._pixiObject.anchor.y = 0.5;
this.updateAlignment();
this.updateTextContent();
this.updateAngle();
@@ -130,7 +123,7 @@ namespace gdjs {
}
updateWrappingWidth(): void {
if (this._object._wordWrap) {
if (this._object._wrapping) {
this._pixiObject.maxWidth =
this._object._wrappingWidth / this._object._scaleX;
this._pixiObject.dirty = true;
@@ -148,13 +141,43 @@ namespace gdjs {
updateAlignment(): void {
// @ts-ignore - assume align is always a valid value.
this._pixiObject.align = this._object._align;
this._pixiObject.align = this._object._textAlign;
this.updatePosition();
}
updatePosition(): void {
this._pixiObject.position.x = this._object.x + this.getWidth() / 2;
this._pixiObject.position.y = this._object.y + this.getHeight() / 2;
if (this._object.isWrapping()) {
const alignmentX =
this._object._textAlign === 'right'
? 1
: this._object._textAlign === 'center'
? 0.5
: 0;
const width = this._object.getWrappingWidth();
// A vector from the custom size center to the renderer center.
const centerToCenterX =
(width - this._pixiObject.width) * (alignmentX - 0.5);
this._pixiObject.position.x = this._object.x + width / 2;
this._pixiObject.anchor.x =
0.5 - centerToCenterX / this._pixiObject.width;
} else {
this._pixiObject.position.x =
this._object.x + this._pixiObject.width / 2;
this._pixiObject.anchor.x = 0.5;
}
const alignmentY =
this._object._verticalTextAlignment === 'bottom'
? 1
: this._object._verticalTextAlignment === 'center'
? 0.5
: 0;
this._pixiObject.position.y =
this._object.y + this._pixiObject.height * (0.5 - alignmentY);
this._pixiObject.anchor.y = 0.5;
}
updateAngle(): void {

View File

@@ -15,12 +15,11 @@ namespace gdjs {
textureAtlasResourceName: string;
/** The scale of the text. */
scale: float;
/** Activate word wrap if set to true. */
wordWrap: boolean;
/** Wrapping with from custom size properties. */
wrappingWidth: float;
/** Alignment of the text. */
align: 'left' | 'center' | 'right';
verticalTextAlignment: 'top' | 'center' | 'bottom';
};
};
export type BitmapTextObjectData = ObjectData & BitmapTextObjectDataType;
@@ -35,6 +34,7 @@ namespace gdjs {
wwrap: boolean;
wwidth: float;
align: string;
vta: string;
};
export type BitmapTextObjectNetworkSyncData = ObjectNetworkSyncData &
@@ -62,9 +62,10 @@ namespace gdjs {
_textureAtlasResourceName: string;
_scaleX: number;
_scaleY: number;
_wordWrap: boolean;
_wrapping: boolean = false;
_wrappingWidth: float;
_align: string;
_textAlign: string;
_verticalTextAlignment: string;
_renderer: gdjs.BitmapTextRuntimeObjectPixiRenderer;
@@ -87,9 +88,10 @@ namespace gdjs {
objectData.content.textureAtlasResourceName; // texture file used with fnt/xml (bitmap font file)
this._scaleX = objectData.content.scale;
this._scaleY = objectData.content.scale;
this._wordWrap = objectData.content.wordWrap;
this._wrappingWidth = 0;
this._align = objectData.content.align;
this._textAlign = objectData.content.align;
this._verticalTextAlignment =
objectData.content.verticalTextAlignment || 'top';
this._renderer = new gdjs.BitmapTextRuntimeObjectRenderer(
this,
@@ -100,12 +102,12 @@ namespace gdjs {
this.onCreated();
}
getRendererObject() {
override getRendererObject() {
return this._renderer.getRendererObject();
}
// @ts-ignore
updateFromObjectData(
override updateFromObjectData(
oldObjectData: BitmapTextObjectDataType,
newObjectData: BitmapTextObjectDataType
): boolean {
@@ -138,17 +140,22 @@ namespace gdjs {
if (oldObjectData.content.scale !== newObjectData.content.scale) {
this.setScale(newObjectData.content.scale);
}
if (oldObjectData.content.wordWrap !== newObjectData.content.wordWrap) {
this.setWordWrap(newObjectData.content.wordWrap);
}
if (oldObjectData.content.align !== newObjectData.content.align) {
this.setAlignment(newObjectData.content.align);
this.setTextAlignment(newObjectData.content.align);
}
if (
oldObjectData.content.verticalTextAlignment !==
newObjectData.content.verticalTextAlignment
) {
this.setVerticalTextAlignment(
newObjectData.content.verticalTextAlignment
);
}
return true;
}
getNetworkSyncData(): BitmapTextObjectNetworkSyncData {
override getNetworkSyncData(): BitmapTextObjectNetworkSyncData {
return {
...super.getNetworkSyncData(),
text: this._text,
@@ -157,13 +164,14 @@ namespace gdjs {
bfrn: this._bitmapFontResourceName,
tarn: this._textureAtlasResourceName,
scale: this.getScale(),
wwrap: this._wordWrap,
wwrap: this._wrapping,
wwidth: this._wrappingWidth,
align: this._align,
align: this._textAlign,
vta: this._verticalTextAlignment,
};
}
updateFromNetworkSyncData(
override updateFromNetworkSyncData(
networkSyncData: BitmapTextObjectNetworkSyncData
): void {
super.updateFromNetworkSyncData(networkSyncData);
@@ -186,30 +194,36 @@ namespace gdjs {
if (this._scaleX !== undefined) {
this.setScale(networkSyncData.scale);
}
if (this._wordWrap !== undefined) {
this.setWordWrap(networkSyncData.wwrap);
if (this._wrapping !== undefined) {
this.setWrapping(networkSyncData.wwrap);
}
if (this._wrappingWidth !== undefined) {
this.setWrappingWidth(networkSyncData.wwidth);
}
if (this._align !== undefined) {
this.setAlignment(networkSyncData.align);
if (this._textAlign !== undefined) {
this.setTextAlignment(networkSyncData.align);
}
if (this._verticalTextAlignment !== undefined) {
this.setVerticalTextAlignment(networkSyncData.vta);
}
}
/**
* Initialize the extra parameters that could be set for an instance.
*/
extraInitializationFromInitialInstance(initialInstanceData: InstanceData) {
override extraInitializationFromInitialInstance(
initialInstanceData: InstanceData
) {
if (initialInstanceData.customSize) {
this.setWrappingWidth(initialInstanceData.width);
this.setWrapping(true);
}
if (initialInstanceData.opacity !== undefined) {
this.setOpacity(initialInstanceData.opacity);
}
}
onDestroyed(): void {
override onDestroyed(): void {
super.onDestroyed();
this._renderer.onDestroy();
}
@@ -314,38 +328,43 @@ namespace gdjs {
return this._textureAtlasResourceName;
}
setAlignment(align: string): void {
this._align = align;
setTextAlignment(align: string): void {
this._textAlign = align;
this._renderer.updateAlignment();
}
getAlignment(): string {
return this._align;
getTextAlignment(): string {
return this._textAlign;
}
/**
* Set object position on X axis.
* @param x The new position X of the object.
* Set the text alignment on Y axis for multiline text objects.
* @param alignment The text alignment.
*/
setX(x: float): void {
setVerticalTextAlignment(alignment: string): void {
this._verticalTextAlignment = alignment;
this._renderer.updatePosition();
}
/**
* Get the text alignment on Y axis of text object.
* @return The text alignment.
*/
getVerticalTextAlignment(): string {
return this._verticalTextAlignment;
}
override setX(x: float): void {
super.setX(x);
this._renderer.updatePosition();
}
/**
* Set object position on Y axis.
* @param y The new position Y of the object.
*/
setY(y: float): void {
override setY(y: float): void {
super.setY(y);
this._renderer.updatePosition();
}
/**
* Set the angle of the object.
* @param angle The new angle of the object.
*/
setAngle(angle: float): void {
override setAngle(angle: float): void {
super.setAngle(angle);
this._renderer.updateAngle();
}
@@ -389,29 +408,34 @@ namespace gdjs {
return this._wrappingWidth;
}
setWordWrap(wordWrap: boolean): void {
this._wordWrap = wordWrap;
setWrapping(wordWrap: boolean): void {
this._wrapping = wordWrap;
this._renderer.updateWrappingWidth();
this.invalidateHitboxes();
}
getWordWrap(): boolean {
return this._wordWrap;
isWrapping(): boolean {
return this._wrapping;
}
/**
* Get the width of the object.
*/
getWidth(): float {
override getWidth(): float {
return this._renderer.getWidth();
}
/**
* Get the height of the object.
*/
getHeight(): float {
override getHeight(): float {
return this._renderer.getHeight();
}
override getDrawableY(): float {
return (
this.getY() -
(this._verticalTextAlignment === 'center'
? this.getHeight() / 2
: this._verticalTextAlignment === 'bottom'
? this.getHeight()
: 0)
);
}
}
gdjs.registerObject(
'BitmapText::BitmapTextObject',

View File

@@ -1,20 +1,23 @@
cmake_minimum_required(VERSION 2.6)
cmake_policy(SET CMP0015 NEW)
cmake_minimum_required(VERSION 3.5)
project(SpineObject)
gd_add_extension_includes()
#Defines
###
# Defines
#
gd_add_extension_definitions(SpineObject)
#The targets
###
# The targets
#
include_directories(.)
file(GLOB source_files *.cpp *.h)
file(
GLOB
source_files
*.cpp
*.h)
gd_add_clang_utils(SpineObject "${source_files}")
gd_add_extension_target(SpineObject "${source_files}")
#Linker files for the IDE extension
###
# Linker files for the IDE extension
#
gd_extension_link_libraries(SpineObject)

View File

@@ -357,8 +357,7 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
obj.AddAction("SetTextAlignment",
_("Alignment"),
_("Set the text alignment of a multiline text object (does not "
"work with single line texts)."),
_("Change the text alignment of a multiline text object."),
_("Align _PARAM0_: _PARAM1_"),
_("Style"),
"res/actions/textAlign24.png",
@@ -388,10 +387,10 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
obj.AddAction(
"SetWrapping",
_("Wrapping"),
_("Word wrapping"),
_("De/activate word wrapping. Note that word wrapping is a graphical "
"option,\nyou can't get the number of lines displayed"),
_("Activate wrapping style of _PARAM0_: _PARAM1_"),
_("Activate word wrapping of _PARAM0_: _PARAM1_"),
_("Style"),
"res/actions/wordWrap24_black.png",
"res/actions/wordWrap_black.png")
@@ -401,8 +400,8 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
obj.AddCondition("IsWrapping",
_("Wrapping"),
_("Test if the word wrapping style of an object is set."),
_("_PARAM0_ word wrapping style is activated"),
_("Check if word wrapping is enabled."),
_("_PARAM0_ word wrapping is enabled"),
_("Style"),
"res/conditions/wordWrap24_black.png",
"res/conditions/wordWrap_black.png")
@@ -411,7 +410,7 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
obj.AddAction("WrappingWidth",
_("Wrapping width"),
_("Modify the word wrapping width of a Text object."),
_("Change the word wrapping width of a Text object."),
_("the wrapping width"),
_("Style"),
"res/actions/wordWrap24_black.png",
@@ -423,7 +422,7 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
obj.AddCondition("WrappingWidth",
_("Wrapping width"),
_("Test the word wrapping width of a Text object."),
_("Compare the word wrapping width of a Text object."),
_("the wrapping width"),
_("Style"),
"res/conditions/wordWrap24_black.png",

View File

@@ -720,7 +720,9 @@ const defineSimpleTileMap = function (extension, _, gd) {
.addObject(
'SimpleTileMap',
_('Tile map'),
_('Displays a tiled-based map.'),
_(
'Displays a tile-based map. Recommended for most games that need to use static tiles.'
),
'JsPlatform/Extensions/tile_map.svg',
objectSimpleTileMap
)
@@ -1520,7 +1522,7 @@ module.exports = {
'TileMap',
_('Tile map'),
_(
"The Tilemap object can be used to display tile-based objects. It's a good way to create maps for RPG, strategy games or create objects by assembling tiles, useful for platformer, retro-looking games, etc..."
"The Tilemap object can be used to display tile-based objects. It's a good way to create maps for RPG, strategy games or create objects by assembling tiles, useful for platformer, retro-looking games, etc... External tilemaps are also supported - but it's recommended to use the built-in, simple Tilemap object for most use cases."
),
'Todor Imreorov',
'Open source (MIT License)'

View File

@@ -122,6 +122,95 @@ gdjs.getPixiRuntimeGameWithAssets = () => {
areaMaxZ: 0,
_initialInnerArea: null,
},
{
name: 'MyLayoutedEventsBasedObject',
isInnerAreaFollowingParentSize: true,
objects: [
{
name: 'MySprite',
type: 'Sprite',
variables: [],
behaviors: [
{
name: 'AnchorBehavior',
type: 'AnchorBehavior::AnchorBehavior',
bottomEdgeAnchor: 2,
leftEdgeAnchor: 1,
relativeToOriginalWindowSize: true,
rightEdgeAnchor: 2,
topEdgeAnchor: 1,
useLegacyBottomAndRightAnchors: false,
},
],
effects: [],
// @ts-ignore This is the object configuration.
updateIfNotVisible: false,
// @ts-ignore This is the object configuration.
animations: [
{
name: 'animation',
directions: [
{
sprites: [
{
image: 'base/tests-utils/assets/64x64.jpg',
originPoint: { name: 'Origin', x: 0, y: 0 },
centerPoint: {
name: 'Center',
x: 32,
y: 32,
automatic: true,
},
points: [
{ name: 'Center', x: 32, y: 32 },
{ name: 'Origin', x: 0, y: 0 },
],
hasCustomCollisionMask: true,
customCollisionMask: [
[
{ x: 64, y: 64 },
{ x: 0, y: 64 },
{ x: 64, y: 0 },
],
],
},
],
timeBetweenFrames: 0,
looping: false,
},
],
useMultipleDirections: false,
},
],
},
],
instances: [
{
angle: 0,
customSize: true,
height: 64,
layer: '',
name: 'MySprite',
persistentUuid: '668db48d-4e12-4b6f-aa6b-f73b74bf608e',
width: 64,
x: 0,
y: 0,
zOrder: 1,
numberProperties: [],
stringProperties: [],
initialVariables: [],
locked: false,
},
],
layers: [],
areaMinX: 0,
areaMinY: 0,
areaMinZ: 0,
areaMaxX: 64,
areaMaxY: 64,
areaMaxZ: 0,
_initialInnerArea: null,
},
],
sceneVariables: [],
globalVariables: [],
@@ -129,8 +218,8 @@ gdjs.getPixiRuntimeGameWithAssets = () => {
],
});
gdjs.getPixiRuntimeGameWithAssets._pixiRuntimeGameWithAssetsPromise = new Promise(
(resolve) => {
gdjs.getPixiRuntimeGameWithAssets._pixiRuntimeGameWithAssetsPromise =
new Promise((resolve) => {
runtimeGame.loadAllAssets(
() => {
console.info('Done loading assets for test game');
@@ -141,8 +230,7 @@ gdjs.getPixiRuntimeGameWithAssets = () => {
/* Ignore progress */
}
);
}
);
});
return gdjs.getPixiRuntimeGameWithAssets._pixiRuntimeGameWithAssetsPromise;
};

View File

@@ -262,6 +262,7 @@ for (const [
attributes.push(
`${attributeName}${optionalReturn ? '?' : ''}: ${returnType};`
);
Parser.skipWhitespaces();
continue;
}

View File

@@ -1,8 +1,8 @@
![GDevelop logo](https://raw.githubusercontent.com/4ian/GDevelop/master/newIDE/GDevelop%20banner.png 'GDevelop logo')
![GDevelop logo](https://raw.githubusercontent.com/4ian/GDevelop/master/newIDE/GDevelop%20banner.png "GDevelop logo")
GDevelop is a **full-featured, no-code, open-source** game development software. You can build **2D, 3D and multiplayer games** for mobile (iOS, Android), desktop and the web. GDevelop is fast and easy to use: the game logic is built up using an intuitive and powerful event-based system and reusable behaviors.
![The GDevelop editor when editing a game level](https://raw.githubusercontent.com/4ian/GDevelop/master/newIDE/GDevelop%20screenshot.png 'The GDevelop editor when editing a game level')
![The GDevelop editor when editing a game level](https://raw.githubusercontent.com/4ian/GDevelop/master/newIDE/GDevelop%20screenshot.png "The GDevelop editor when editing a game level")
## Getting started
@@ -13,7 +13,7 @@ GDevelop is a **full-featured, no-code, open-source** game development software.
| 🧑‍💻 Contribute to the editor or game engine | Follow this [README](newIDE/README.md). |
| 👾 Create or sell a game template | Submit a [free example or a paid template on the Asset Store](https://wiki.gdevelop.io/gdevelop5/community/guide-for-submitting-an-example/). |
| 🎨 Share or sell an asset pack | Submit a [free or paid asset pack on the Asset Store](https://wiki.gdevelop.io/gdevelop5/community/sell-asset-pack-store). |
| 🌐 Help to translate GDevelop | Go on the [GDevelop project on Crowdin](https://crowdin.com/project/gdevelop) or translate [in-app tutorials](https://github.com/GDevelopApp/GDevelop-tutorials). |
| 🌐 Help translate GDevelop | Go on the [GDevelop project on Crowdin](https://crowdin.com/project/gdevelop) or translate [in-app tutorials](https://github.com/GDevelopApp/GDevelop-tutorials). |
| 👥 Get online game services or commercial support | See offers for [professionals, teams or individual creators](https://gdevelop.io/pricing). |
> Are you interested in contributing to GDevelop for the first time? Take a look at the list of **[good first issues](https://github.com/4ian/GDevelop/issues?q=is%3Aissue+is%3Aopen+label%3A%22%F0%9F%91%8Cgood+first+issue%22)**, **[good first contributions](https://github.com/4ian/GDevelop/discussions/categories/good-first-contribution)** or the **["🏐 not too hard" cards](https://trello.com/b/qf0lM7k8/gdevelop-roadmap?menu=filter&filter=label:Not%20too%20hard%20%E2%9A%BD%EF%B8%8F)** on the Roadmap.
@@ -24,7 +24,7 @@ GDevelop is a **full-featured, no-code, open-source** game development software.
- See the [showcase of games](https://gdevelop.io/games) created with GDevelop and published on Steam, iOS (App Store), Android (Google Play), Itch.io, Newgrounds, CrazyGames, Poki...
- Suggest your game to be [added to the showcase here](https://docs.google.com/forms/d/e/1FAIpQLSfjiOnkbODuPifSGuzxYY61vB5kyMWdTZSSqkJsv3H6ePRTQA/viewform).
[![Some games made with GDevelop](https://raw.githubusercontent.com/4ian/GDevelop/master/newIDE/GDevelop%20games.png 'Some games made with GDevelop')](https://gdevelop.io/games)
[![Some games made with GDevelop](https://raw.githubusercontent.com/4ian/GDevelop/master/newIDE/GDevelop%20games.png "Some games made with GDevelop")](https://gdevelop.io/games)
## Technical architecture

View File

@@ -0,0 +1,29 @@
// This file customizes webpack configuration for react-app-rewired.
module.exports = {
webpack: function override(config, env) {
config.module.rules.push({
test: /\.worker\.js$/,
use: {
loader: 'worker-loader',
options: {
filename: '[name].[contenthash].worker.js',
},
},
});
// A lot of packages we use in node_modules trigger source map warnings
// but it is not a blocking issue, so we ignore them.
config.ignoreWarnings = [/Failed to parse source map/];
return config;
},
jest: function(config) {
config.transformIgnorePatterns = [
'<rootDir>/node_modules/(?!react-markdown|unified|remark-parse|mdast-util-from-markdown|micromark|decode-named-character-reference|remark-rehype|trim-lines|hast-util-whitespace|remark-gfm|mdast-util-gfm|mdast-util-find-and-replace|mdast-util-to-markdown|markdown-table|is-plain-obj)',
];
return config;
},
};

View File

@@ -92,6 +92,7 @@
"minimist": "1.2.5",
"patch-package": "^6.4.7",
"prettier": "1.15.3",
"react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1",
"recursive-copy": "^2.0.14",
"recursive-readdir": "^2.2.2",
@@ -100,7 +101,8 @@
"style-dictionary": "^2.10.2",
"typescript": "^4.1.3",
"webpack": "5.88.2",
"workbox-build": "^4.3.1"
"workbox-build": "^4.3.1",
"worker-loader": "^3.0.8"
}
},
"node_modules/@algolia/events": {
@@ -27159,6 +27161,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-app-rewired": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.2.1.tgz",
"integrity": "sha512-uFQWTErXeLDrMzOJHKp0h8P1z0LV9HzPGsJ6adOtGlA/B9WfT6Shh4j2tLTTGlXOfiVx6w6iWpp7SOC5pvk+gA==",
"dev": true,
"dependencies": {
"semver": "^5.6.0"
},
"bin": {
"react-app-rewired": "bin/index.js"
},
"peerDependencies": {
"react-scripts": ">=2.1.3"
}
},
"node_modules/react-app-rewired/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/react-base16-styling": {
"version": "0.6.0",
"license": "MIT",
@@ -35014,6 +35040,40 @@
"workbox-core": "^4.3.1"
}
},
"node_modules/worker-loader": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-3.0.8.tgz",
"integrity": "sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==",
"dev": true,
"dependencies": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/worker-loader/node_modules/loader-utils": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
},
"engines": {
"node": ">=8.9.0"
}
},
"node_modules/wrap-ansi": {
"version": "2.1.0",
"dev": true,

View File

@@ -28,6 +28,7 @@
"minimist": "1.2.5",
"patch-package": "^6.4.7",
"prettier": "1.15.3",
"react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1",
"recursive-copy": "^2.0.14",
"recursive-readdir": "^2.2.2",
@@ -36,7 +37,8 @@
"style-dictionary": "^2.10.2",
"typescript": "^4.1.3",
"webpack": "5.88.2",
"workbox-build": "^4.3.1"
"workbox-build": "^4.3.1",
"worker-loader": "^3.0.8"
},
"dependencies": {
"@blueprintjs/core": "file:src/Utils/BlueprintJsPlaceholder",
@@ -60,6 +62,8 @@
"lodash": "4.17.4",
"node-require-function": "^1.2.0",
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "^1.57.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
@@ -93,9 +97,7 @@
"three": "0.160.0",
"url-search-params": "^1.0.2",
"wavesurfer.js": "^7.8.6",
"xxhashjs": "^0.2.2",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2"
"xxhashjs": "^0.2.2"
},
"overrides": {
"pixi-spine": {
@@ -113,17 +115,17 @@
"import-resources": "npm run import-zipped-external-editors && npm run build-theme-resources && cd scripts && node import-libGD.js && node import-GDJS-Runtime.js && node import-monaco-editor.js && node import-zipped-external-libs.js",
"make-version-metadata": "cd scripts && node make-version-metadata.js",
"make-service-worker": "cd scripts && node make-service-worker.js",
"start": "npm run import-resources && npm run make-version-metadata && react-scripts start",
"start": "npm run import-resources && npm run make-version-metadata && react-app-rewired start",
"electron-app": "cd ../electron-app && npm run start",
"build": "npm run import-resources && npm run make-version-metadata && react-scripts build && npm run make-service-worker",
"build": "npm run import-resources && npm run make-version-metadata && react-app-rewired build && npm run make-service-worker",
"format": "prettier --write \"src/!(locales)/**/*.js\"",
"check-format": "prettier --list-different \"src/!(locales)/**/*.js\"",
"test": "react-scripts test --env=node",
"test": "react-app-rewired test --env=node",
"flow": "flow",
"check-script-types": "cd scripts && tsc",
"storybook": "storybook dev -p 9009",
"build-storybook": "storybook build -o build-storybook",
"analyze-test-coverage": "react-scripts test --env=node --coverage",
"analyze-test-coverage": "react-app-rewired test --env=node --coverage",
"analyze-flow-coverage": "flow-coverage-report",
"analyze-source-map": "source-map-explorer build/static/js/*.js",
"extract-all-translations": "node scripts/extract-all-translations.js",
@@ -131,7 +133,7 @@
"reload-extensions": "node scripts/import-GDJS-Runtime.js --skip-clean",
"build-theme-resources": "node scripts/build-theme-resources.js",
"create-new-theme": "node scripts/create-new-theme.js",
"import-zipped-external-editors": "cd scripts && node import-zipped-editor.js piskel 5.0.0-beta82 b8e4d57b160ff93d3680168cd271af795412ea6c4c0da321aee2946345c7fb75 && node import-zipped-editor.js jfxr 5.0.0-beta55 8ac12b557c2ddba958c6f0d3e0c5df8cf3369a65262dcb90cf5c8a7a7d20bdf6 && node import-zipped-editor.js yarn 5.0.134 ba8558cad00ec9b18cf3c6fd8647f8c1478ca67c894bca94a152a3740af209cc"
"import-zipped-external-editors": "cd scripts && node import-zipped-editor.js piskel 5.5.228 b161dc74582e428a6d210cd1b74f052ca4aab301d0d522e0be87bdb4962d0fb7 && node import-zipped-editor.js jfxr 5.0.0-beta55 8ac12b557c2ddba958c6f0d3e0c5df8cf3369a65262dcb90cf5c8a7a7d20bdf6 && node import-zipped-editor.js yarn 5.0.134 ba8558cad00ec9b18cf3c6fd8647f8c1478ca67c894bca94a152a3740af209cc"
},
"eslintConfig": {
"extends": "react-app",
@@ -193,10 +195,5 @@
"not dead",
"not ie <= 11",
"not op_mini all"
],
"jest": {
"transformIgnorePatterns": [
"<rootDir>/node_modules/(?!react-markdown|unified|remark-parse|mdast-util-from-markdown|micromark|decode-named-character-reference|remark-rehype|trim-lines|hast-util-whitespace|remark-gfm|mdast-util-gfm|mdast-util-find-and-replace|mdast-util-to-markdown|markdown-table|is-plain-obj)"
]
}
]
}

View File

@@ -1,9 +1,8 @@
This folder contains sources to embed the Piskel editor (https://www.piskelapp.com/) so that it can
be used directly from GDevelop to edit images.
GD uses an updated version of piskel from <https://github.com/blurymind/piskel/tree/piskel-plus>
GD uses an updated version of piskel from <https://github.com/GDevelopApp/piskel> on `master` branch.
It contains a number of advanced color manipulation features and improvements to aid artists.
commit number: a300d17eb88d2b9d1fa2bbe3a810ad1bb76f1f81
Piskel sources are downloaded by `import-zipped-editor.js` script. They are raw, unchanged sources
of the Piskel editor build. Sources will be stored in the `piskel-editor` folder.

View File

@@ -4,7 +4,6 @@
* The zip should be uploaded with one of the git releases (use gitRelease variable for version where you released it)
*/
var shell = require('shelljs');
var fs = require('fs');
var AdmZip = require('adm-zip');
var process = require('process');
var path = require('path');

View File

@@ -55,6 +55,7 @@ const buildSW = () => {
'static/media/*',
'static/js/locales-*.js', // Locales.
'static/js/!local-app*.js', // Exclude electron app.
'static/js/Resource3DPreview.worker.*.js', // Include the 3D preview worker
// ...But not libGD.js/wasm (there are cached with their URL
// query string that depends on the VersionMetadata, see below).

View File

@@ -28,7 +28,7 @@ import {
import { useDebounce } from '../Utils/UseDebounce';
import PromotionsSlideshow from '../Promotions/PromotionsSlideshow';
import { ColumnStackLayout } from '../UI/Layout';
import { EarnCredits } from '../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
import { EarnCredits } from '../GameDashboard/Wallet/EarnCredits';
const cellSpacing = 2;

View File

@@ -27,7 +27,11 @@ type Props = {|
const CompactPropertiesEditorRowField = (props: Props) => {
const title = !props.markdownDescription
? props.label
: [props.label, ' - ', <MarkdownText source={props.markdownDescription} />];
: [
props.label,
' - ',
<MarkdownText key="markdown-desc" source={props.markdownDescription} />,
];
return (
<LineStackLayout noMargin alignItems="center" expand>
<div style={styles.leftColumn}>

View File

@@ -0,0 +1,91 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import type { CourseChapter } from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { LineStackLayout } from '../UI/Layout';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
const styles = {
titleContainer: {
flex: 1,
display: 'flex',
},
statusContainer: {
display: 'flex',
alignItems: 'center',
gap: 4,
},
};
type Props = {|
chapterIndex: number,
courseChapter: CourseChapter,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|};
const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
({ chapterIndex, courseChapter, getChapterCompletion }, ref) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { isMobile, isLandscape } = useResponsiveWindowSize();
const isMobilePortrait = isMobile && !isLandscape;
const completion = getChapterCompletion(courseChapter.id);
const isFinished = completion
? completion.completedTasks >= completion.tasks
: false;
return (
<div
ref={ref}
style={{
...styles.titleContainer,
flexDirection: isMobilePortrait ? 'column-reverse' : 'row',
alignItems: isMobilePortrait ? 'flex-start' : 'center',
justifyContent: 'space-between',
}}
>
<LineStackLayout noMargin alignItems="center" expand>
<Text size="title">
{chapterIndex + 1}. {courseChapter.title}
</Text>
{isFinished && !isMobilePortrait && (
<div
style={{
display: 'flex',
color: gdevelopTheme.statusIndicator.success,
}}
>
<CheckCircle />
</div>
)}
</LineStackLayout>
{isFinished ? (
<div
style={{
...styles.statusContainer,
color: gdevelopTheme.statusIndicator.success,
}}
>
{isMobilePortrait && <CheckCircle />}
<Text color="inherit" noMargin>
<Trans>Finished</Trans>
</Text>
</div>
) : completion ? (
<Text color="secondary" noMargin>
<Trans>
{completion.completedTasks} of {completion.tasks} completed
</Trans>
</Text>
) : null}
</div>
);
}
);
export default CourseChapterTitle;

View File

@@ -1,426 +0,0 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import type { CourseChapter } from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../UI/Layout';
import Paper from '../UI/Paper';
import RaisedButton from '../UI/RaisedButton';
import { Line, Spacer } from '../UI/Grid';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import Divider from '@material-ui/core/Divider';
import FlatButton from '../UI/FlatButton';
import ChevronArrowBottom from '../UI/CustomSvgIcons/ChevronArrowBottom';
import ChevronArrowRight from '../UI/CustomSvgIcons/ChevronArrowRight';
import Cloud from '../UI/CustomSvgIcons/Cloud';
import CourseChapterTaskItem from './CourseChapterTaskItem';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
import GoldCompact from '../Profile/Subscription/Icons/GoldCompact';
import Coin from '../Credits/Icons/Coin';
import Lock from '../UI/CustomSvgIcons/Lock';
import { rankLabel } from '../Utils/Ordinal';
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
import Window from '../Utils/Window';
import PasswordPromptDialog from '../AssetStore/PasswordPromptDialog';
const getYoutubeVideoIdFromUrl = (youtubeUrl: ?string): ?string => {
if (!youtubeUrl || !youtubeUrl.startsWith('https://youtu.be/')) return null;
try {
const url = new URL(youtubeUrl);
const lastPartOfUrl = url.pathname.split('/').pop();
if (!lastPartOfUrl || !lastPartOfUrl.length) {
console.error(`The video URL is badly formatted ${youtubeUrl}`);
return null;
}
return lastPartOfUrl;
} catch (error) {
console.error(`Could not parse youtube url ${youtubeUrl}:`, error);
return null;
}
};
const styles = {
icon: {
fontSize: 18,
},
stickyTitle: {
position: 'sticky',
top: -1, // If 0, it somehow lets a 1px gap between the parent, letting the user see the text scroll behind.
display: 'flex',
flexDirection: 'column',
zIndex: 2,
},
titleContainer: {
flex: 1,
display: 'flex',
},
statusContainer: {
display: 'flex',
alignItems: 'center',
gap: 4,
},
videoAndMaterialsContainer: {
display: 'flex',
marginTop: 8,
gap: 8,
alignItems: 'stretch',
flexWrap: 'wrap',
marginBottom: 8,
flex: 1,
minWidth: 0,
},
lockedChapterTextContainer: {
// Similar to Line component but without the minWidth:0 that somehow
// prevents container to wrap when overflowing.
display: 'flex',
flex: 1,
minHeight: 0,
},
videoContainer: {
flex: 2,
minWidth: 300,
display: 'flex',
position: 'relative',
},
videoIFrame: { flex: 1, aspectRatio: '16 / 9' },
videoThumbnail: {
flex: 1,
aspectRatio: '16 / 9',
objectFit: 'cover',
width: '100%',
borderRadius: 4,
},
lockerImage: { height: 60, width: 60 },
lockedOverlay: {
position: 'absolute',
background: 'rgba(0, 0, 0, 0.6)',
borderRadius: 4,
inset: 0,
height: `100%`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: 'white', // Force text/icon color since it's on a dark overlay.
},
sideBar: { padding: 16, display: 'flex' },
};
const LockedOverlay = () => (
<div style={styles.lockedOverlay}>
<Lock style={styles.lockerImage} />
</div>
);
type Props = {|
chapterIndex: number,
courseChapter: CourseChapter,
onOpenTemplate: () => void,
onCompleteTask: (
chapterId: string,
taskIndex: number,
completed: boolean
) => void,
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|};
const CourseChapterView = React.forwardRef<Props, HTMLDivElement>(
(
{
chapterIndex,
courseChapter,
onOpenTemplate,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
onBuyWithCredits,
},
ref
) => {
const { openSubscriptionDialog } = React.useContext(
SubscriptionSuggestionContext
);
const {
values: { language },
} = React.useContext(PreferencesContext);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const userLanguage2LetterCode = language.split('_')[0];
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize();
const isMobilePortrait = isMobile && !isLandscape;
const [openTasks, setOpenTasks] = React.useState<boolean>(false);
const completion = getChapterCompletion(courseChapter.id);
const isFinished = completion
? completion.completedTasks >= completion.tasks
: false;
const youtubeVideoId = getYoutubeVideoIdFromUrl(courseChapter.videoUrl);
const [isPurchasing, setIsPurchasing] = React.useState<boolean>(false);
const onClickBuyWithCredits = React.useCallback(
async () => {
if (!courseChapter.isLocked) return;
setDisplayPasswordPrompt(false);
setIsPurchasing(true);
try {
await onBuyWithCredits(courseChapter, password);
} finally {
setIsPurchasing(false);
}
},
[courseChapter, onBuyWithCredits, password]
);
const onWillBuyWithCredits = React.useCallback(
async () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onClickBuyWithCredits();
},
[onClickBuyWithCredits]
);
return (
<ColumnStackLayout expand noMargin>
<div
ref={ref}
style={{
...styles.titleContainer,
flexDirection: isMobilePortrait ? 'column-reverse' : 'row',
alignItems: isMobilePortrait ? 'flex-start' : 'center',
justifyContent: 'space-between',
}}
>
<LineStackLayout noMargin alignItems="center" expand>
<Text size="title">
{chapterIndex + 1}. {courseChapter.title}
</Text>
{isFinished && !isMobilePortrait && (
<div
style={{
display: 'flex',
color: gdevelopTheme.statusIndicator.success,
}}
>
<CheckCircle />
</div>
)}
</LineStackLayout>
{isFinished ? (
<div
style={{
...styles.statusContainer,
color: gdevelopTheme.statusIndicator.success,
}}
>
{isMobilePortrait && <CheckCircle />}
<Text color="inherit" noMargin>
<Trans>Finished</Trans>
</Text>
</div>
) : completion ? (
<Text color="secondary" noMargin>
<Trans>
{completion.completedTasks} of {completion.tasks} completed
</Trans>
</Text>
) : null}
</div>
{courseChapter.isLocked ? (
<div style={styles.videoAndMaterialsContainer}>
{youtubeVideoId && (
<div
style={{
...styles.videoContainer,
maxWidth: windowSize === 'xlarge' ? 960 : 640,
}}
>
<img
alt={`Video for lesson ${courseChapter.title}`}
style={styles.videoThumbnail}
src={`https://i.ytimg.com/vi/${youtubeVideoId}/sddefault.jpg`}
/>
<LockedOverlay />
</div>
)}
<div style={styles.lockedChapterTextContainer}>
<Paper background="medium" style={styles.sideBar}>
<ColumnStackLayout noMargin justifyContent="center">
<Text noMargin size="sub-title">
<Trans>Unlock this lesson to finish the course</Trans>
</Text>
<Text noMargin>
<Trans>
Use your GDevelop credits to purchase lessons in this
course; or get a subscription to get them for free.
</Trans>
</Text>
<ResponsiveLineStackLayout
noMargin
noColumnMargin
forceMobileLayout={
windowSize === 'small' || windowSize === 'medium'
}
>
<RaisedButton
primary
fullWidth
icon={<GoldCompact fontSize="small" />}
disabled={isPurchasing}
label={<Trans>Get a subscription</Trans>}
onClick={() =>
openSubscriptionDialog({
analyticsMetadata: {
reason: 'Unlock course chapter',
recommendedPlanId: 'gdevelop_silver',
},
})
}
/>
{courseChapter.priceInCredits && (
<FlatButton
fullWidth
leftIcon={<Coin fontSize="small" />}
disabled={isPurchasing}
label={
<Trans>
Pay {courseChapter.priceInCredits} credits
</Trans>
}
onClick={onWillBuyWithCredits}
/>
)}
</ResponsiveLineStackLayout>
</ColumnStackLayout>
</Paper>
</div>
</div>
) : (
<div style={styles.videoAndMaterialsContainer}>
{youtubeVideoId && (
<div
style={{
...styles.videoContainer,
maxWidth: windowSize === 'xlarge' ? 960 : 640,
}}
>
<iframe
title={`Video for lesson ${courseChapter.title}`}
type="text/html"
style={styles.videoIFrame}
src={`https://www.youtube.com/embed/${youtubeVideoId}?cc_load_policy=1&cc_lang_pref=${
// Having another language than `en` as the requested caption language prevents the player from displaying the auto-translated captions.
'en'
}&hl=${userLanguage2LetterCode}`}
frameBorder="0"
/>
</div>
)}
<ColumnStackLayout noMargin expand>
<Text size="sub-title" noMargin>
<Trans>Chapter materials</Trans>
</Text>
<Paper background="medium" style={styles.sideBar}>
<ColumnStackLayout noMargin>
<Line noMargin>
<Text noMargin>{rankLabel[chapterIndex + 1]}</Text>
&nbsp;
<Text noMargin>
<Trans>Chapter</Trans>
</Text>
&nbsp;-&nbsp;
<Text noMargin>
<Trans>Template</Trans>
</Text>
</Line>
<Line noMargin>
<RaisedButton
primary
icon={<Cloud fontSize="small" />}
label={<Trans>Open template</Trans>}
onClick={onOpenTemplate}
/>
</Line>
</ColumnStackLayout>
</Paper>
</ColumnStackLayout>
</div>
)}
{!courseChapter.isLocked && (
<div
style={{
...styles.stickyTitle,
backgroundColor: gdevelopTheme.paper.backgroundColor.dark,
}}
>
<Divider />
<Spacer />
<Line alignItems="center" justifyContent="space-between" noMargin>
<Text size="block-title">
<Trans>Tasks</Trans>
</Text>
<FlatButton
primary
label={
openTasks ? (
<Trans>Close all tasks</Trans>
) : (
<Trans>Open all tasks</Trans>
)
}
leftIcon={
openTasks ? (
<ChevronArrowBottom style={styles.icon} />
) : (
<ChevronArrowRight style={styles.icon} />
)
}
onClick={() => setOpenTasks(!openTasks)}
/>
</Line>
<Spacer />
<Divider />
</div>
)}
{!courseChapter.isLocked &&
courseChapter.tasks.map((item, taskIndex) => (
<CourseChapterTaskItem
courseChapterTask={item}
key={taskIndex.toString()}
isOpen={openTasks}
isComplete={isTaskCompleted(courseChapter.id, taskIndex)}
onComplete={isCompleted =>
onCompleteTask(courseChapter.id, taskIndex, isCompleted)
}
/>
))}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onClickBuyWithCredits}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</ColumnStackLayout>
);
}
);
export default CourseChapterView;

View File

@@ -31,6 +31,8 @@ import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import Lock from '../UI/CustomSvgIcons/Lock';
import LockOpen from '../UI/CustomSvgIcons/LockOpen';
import EmptyBadge from '../UI/CustomSvgIcons/EmptyBadge';
import Skeleton from '@material-ui/lab/Skeleton';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
const styles = {
container: { padding: 16, display: 'flex', borderRadius: 8 },
@@ -57,7 +59,7 @@ const styles = {
color: '#1D1D26',
},
gdevelopAvatar: { width: 20, height: 20 },
thumbnail: { borderRadius: 4, aspectRatio: '16 / 9' },
thumbnail: { borderRadius: 4, aspectRatio: '16 / 9', maxWidth: '100%' },
statusContainer: {
display: 'flex',
alignItems: 'center',
@@ -68,7 +70,11 @@ const styles = {
},
emptyBadgeContainer: {
display: 'flex',
opacity: 0.5,
},
badgeIcon: {
height: 28,
width: 28,
objectFit: 'cover',
},
};
@@ -151,10 +157,11 @@ const ChapterTile = ({
};
type Props = {|
course: Course,
courseChapters: CourseChapter[],
getCourseCompletion: () => CourseCompletion | null,
course: ?Course,
courseChapters: ?(CourseChapter[]),
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onDisplayCourse: boolean => void,
@@ -168,20 +175,46 @@ const CoursePreviewBanner = ({
onDisplayCourse,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { achievements, badges } = React.useContext(AuthenticatedUserContext);
const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize();
const courseCompletion = getCourseCompletion();
const courseCompletion = course ? getCourseCompletion(course.id) : null;
const numberOfTilesToDisplay = isMobile ? 2 : windowSize === 'xlarge' ? 5 : 4;
const chapterTiles = React.useMemo(
() => {
if (!course || !courseChapters) {
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => {
return (
<React.Fragment key={`skeleton-${index}`}>
{index > 0 &&
(isMobile && !isLandscape ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
))}
{index > 0 && <Spacer />}
<Column expand>
<Skeleton height={40} />
<Skeleton height={20} />
<Skeleton height={60} />
<LargeSpacer />
</Column>
</React.Fragment>
);
});
}
const completionByChapter = new Array(course.chaptersTargetCount)
.fill(0)
.map((_, index) => {
const chapter = courseChapters[index];
if (!chapter) return false;
const chapterCompletion = getCourseChapterCompletion(
courseChapters[index].id
course.id,
chapter.id
);
if (!chapterCompletion) return false;
return chapterCompletion.completedTasks >= chapterCompletion.tasks;
@@ -235,7 +268,7 @@ const CoursePreviewBanner = ({
});
},
[
course.chaptersTargetCount,
course,
courseChapters,
getCourseChapterCompletion,
numberOfTilesToDisplay,
@@ -245,7 +278,7 @@ const CoursePreviewBanner = ({
]
);
const renderProgress = () => (
const renderProgress = (course: Course) => (
<LineStackLayout alignItems="center" noMargin expand>
<Text size="sub-title">
<Trans>{course.chaptersTargetCount} chapters</Trans>:
@@ -270,10 +303,30 @@ const CoursePreviewBanner = ({
</LineStackLayout>
);
const badgeUrl = React.useMemo(
() => {
if (!course) return null;
const achievementId = `course-${course.id}`;
const matchingAchievement =
achievementId && achievements
? achievements.find(achievement => achievement.id === achievementId)
: null;
if (!matchingAchievement) return null;
const hasBadge =
badges &&
matchingAchievement &&
!!badges.find(badge => badge.achievementId === matchingAchievement.id);
if (!hasBadge) return null;
return matchingAchievement.iconUrl;
},
[course, badges, achievements]
);
return (
<I18n>
{({ i18n }) => (
<Paper background="medium">
<Paper background="medium" variant="outlined">
<ButtonBase
onClick={() => onDisplayCourse(true)}
component="div"
@@ -283,108 +336,158 @@ const CoursePreviewBanner = ({
: styles.container
}
>
<ResponsiveLineStackLayout
noResponsiveLandscape
noMargin
noColumnMargin
noOverflowParent
>
<div
style={{
...styles.leftColumn,
width: isMobile && !isLandscape ? '100%' : 220,
}}
<Column expand noMargin>
<ResponsiveLineStackLayout
noResponsiveLandscape
noMargin
noColumnMargin
noOverflowParent
>
<Line noMargin>
<Chip
label={<Trans>Recommended for you</Trans>}
style={styles.chip}
/>
</Line>
<Text noMargin size="block-title">
{selectMessageByLocale(i18n, course.titleByLocale)}
</Text>
<LineStackLayout noMargin alignItems="center">
<Avatar
src="./res/gdevelop-logo-b-w.png"
style={styles.gdevelopAvatar}
/>
<Text noMargin>GDevelop</Text>
</LineStackLayout>
<img
src="https://public-resources.gdevelop.io/course/gdevelop-premium-course.jpeg"
alt="Red hero buffed by knowledge"
style={styles.thumbnail}
onClick={() => onDisplayCourse(true)}
/>
</div>
<ColumnStackLayout expand noMargin>
{isMobile && !isLandscape ? (
<Column noMargin>
<Text color="secondary" noMargin>
{selectMessageByLocale(i18n, course.levelByLocale)}
&nbsp;&nbsp;
<Trans>{course.durationInWeeks} weeks</Trans>
</Text>
{renderProgress()}
</Column>
) : (
<LineStackLayout noMargin alignItems="center" expand>
{renderProgress()}
<Text color="secondary" noMargin>
{selectMessageByLocale(i18n, course.levelByLocale)}
&nbsp;&nbsp;
<Trans>{course.durationInWeeks} weeks</Trans>
</Text>
</LineStackLayout>
)}
<ResponsiveLineStackLayout noResponsiveLandscape>
{chapterTiles}
</ResponsiveLineStackLayout>
<Paper
style={
isMobile && !isLandscape
? styles.mobileBadgePaper
: styles.badgePaper
}
background="light"
<div
style={{
...styles.leftColumn,
width: isMobile && !isLandscape ? '100%' : 220,
}}
>
<ResponsiveLineStackLayout
noResponsiveLandscape
noColumnMargin
alignItems="center"
noMargin
justifyContent="space-between"
>
<LineStackLayout alignItems="center" noMargin>
<div
style={{
...styles.emptyBadgeContainer,
color: gdevelopTheme.text.color.secondary,
}}
>
<EmptyBadge />
</div>
<Text noMargin>
<Trans>Earn an exclusive badge</Trans>
<Line noMargin>
<Chip
label={<Trans>Recommended for you</Trans>}
style={styles.chip}
/>
</Line>
{course ? (
<Text noMargin size="block-title">
{selectMessageByLocale(i18n, course.titleByLocale)}
</Text>
) : (
<Skeleton height={40} />
)}
<LineStackLayout noMargin alignItems="center">
<Avatar
src="./res/gdevelop-logo-b-w.png"
style={styles.gdevelopAvatar}
/>
<Text noMargin>GDevelop</Text>
</LineStackLayout>
{course ? (
<img
src={selectMessageByLocale(i18n, course.imageUrlByLocale)}
alt=""
style={styles.thumbnail}
/>
) : (
<Skeleton
width={isMobile && !isLandscape ? undefined : 220}
variant="rect"
height={124}
/>
)}
</div>
<ColumnStackLayout expand noMargin>
{isMobile && !isLandscape ? (
course ? (
<Column noMargin>
<Text color="secondary" noMargin>
{selectMessageByLocale(i18n, course.levelByLocale)}
&nbsp;&nbsp;
<Trans>{course.durationInWeeks} weeks</Trans>
</Text>
{renderProgress(course)}
</Column>
) : (
<Column noMargin>
<Skeleton />
<Skeleton />
</Column>
)
) : course ? (
<LineStackLayout noMargin alignItems="center" expand>
{renderProgress(course)}
<Text color="secondary" noMargin>
{selectMessageByLocale(i18n, course.levelByLocale)}
&nbsp;&nbsp;
<Trans>{course.durationInWeeks} weeks</Trans>
</Text>
</LineStackLayout>
<RaisedButton
primary
label={
!courseCompletion ||
courseCompletion.percentage === 0 ? (
<Trans>Start learning</Trans>
) : (
<Trans>Keep learning</Trans>
)
}
onClick={() => onDisplayCourse(true)}
/>
) : (
<Column noMargin expand>
<Skeleton height={30} />
</Column>
)}
<ResponsiveLineStackLayout noResponsiveLandscape>
{chapterTiles}
</ResponsiveLineStackLayout>
</Paper>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
<Paper
style={
isMobile && !isLandscape
? styles.mobileBadgePaper
: styles.badgePaper
}
background="light"
>
<ResponsiveLineStackLayout
noResponsiveLandscape
noColumnMargin
alignItems="center"
noMargin
justifyContent="space-between"
>
<LineStackLayout alignItems="center" noMargin>
<div
style={{
...styles.emptyBadgeContainer,
opacity: badgeUrl ? 1 : 0.5,
color: gdevelopTheme.text.color.secondary,
}}
>
{badgeUrl ? (
<img
src={badgeUrl}
alt="Course badge"
style={styles.badgeIcon}
/>
) : (
<EmptyBadge />
)}
</div>
<Text noMargin>
{badgeUrl ? (
<Trans>Congrats on finishing this course!</Trans>
) : courseCompletion &&
courseCompletion.percentage === 1 ? (
// If user does not have the badge but has completed the course
// (possible if they finished the course before the badge logic was
// implemented), this copy acts as a hint for the user to undo/redo
// a task to trigger the logic that awards the badge.
<Trans>
Complete all tasks to claim your badge
</Trans>
) : (
<Trans>Earn an exclusive badge</Trans>
)}
</Text>
</LineStackLayout>
<RaisedButton
primary
disabled={!course || !courseChapters}
label={
!courseCompletion ||
courseCompletion.percentage === 0 ? (
<Trans>Start learning</Trans>
) : courseCompletion.percentage === 1 ? (
<Trans>Open</Trans>
) : (
<Trans>Keep learning</Trans>
)
}
onClick={() => onDisplayCourse(true)}
/>
</ResponsiveLineStackLayout>
</Paper>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
</Column>
</ButtonBase>
</Paper>
)}

View File

@@ -0,0 +1,210 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import type {
CourseChapter,
LockedVideoBasedCourseChapter,
LockedTextBasedCourseChapter,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
import Paper from '../UI/Paper';
import RaisedButton from '../UI/RaisedButton';
import FlatButton from '../UI/FlatButton';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
import GoldCompact from '../Profile/Subscription/Icons/GoldCompact';
import Coin from '../Credits/Icons/Coin';
import Lock from '../UI/CustomSvgIcons/Lock';
import Window from '../Utils/Window';
import PasswordPromptDialog from '../AssetStore/PasswordPromptDialog';
import { getYoutubeVideoIdFromUrl } from '../Utils/Youtube';
import AlertMessage from '../UI/AlertMessage';
const styles = {
videoAndMaterialsContainer: {
display: 'flex',
marginTop: 8,
gap: 8,
alignItems: 'stretch',
flexWrap: 'wrap',
marginBottom: 8,
flex: 1,
minWidth: 0,
},
lockedChapterTextContainer: {
// Similar to Line component but without the minWidth:0 that somehow
// prevents container to wrap when overflowing.
display: 'flex',
flex: 1,
minHeight: 0,
},
videoContainer: {
flex: 2,
minWidth: 300,
display: 'flex',
position: 'relative',
},
videoThumbnail: {
flex: 1,
aspectRatio: '16 / 9',
objectFit: 'cover',
width: '100%',
borderRadius: 4,
},
lockerImage: { height: 60, width: 60 },
lockedOverlay: {
position: 'absolute',
background: 'rgba(0, 0, 0, 0.6)',
borderRadius: 4,
inset: 0,
height: `100%`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: 'white', // Force text/icon color since it's on a dark overlay.
},
sideBar: { padding: 16, display: 'flex' },
};
const LockedOverlay = () => (
<div style={styles.lockedOverlay}>
<Lock style={styles.lockerImage} />
</div>
);
type Props = {|
courseChapter: LockedVideoBasedCourseChapter | LockedTextBasedCourseChapter,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|};
const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
({ courseChapter, onBuyWithCredits }, ref) => {
const { openSubscriptionDialog } = React.useContext(
SubscriptionSuggestionContext
);
const [error, setError] = React.useState<React.Node>(null);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const { windowSize } = useResponsiveWindowSize();
const [password, setPassword] = React.useState<string>('');
const youtubeVideoId = courseChapter.videoUrl
? getYoutubeVideoIdFromUrl(courseChapter.videoUrl)
: null;
const [isPurchasing, setIsPurchasing] = React.useState<boolean>(false);
const onClickBuyWithCredits = React.useCallback(
async () => {
if (!courseChapter.isLocked) return;
setError(null);
setDisplayPasswordPrompt(false);
setIsPurchasing(true);
try {
await onBuyWithCredits(courseChapter, password);
} catch (error) {
console.error('An error occurred while buying this chapter', error);
setError(
<Trans>
An error occurred while buying this chapter. Please try again
later.
</Trans>
);
} finally {
setIsPurchasing(false);
}
},
[courseChapter, onBuyWithCredits, password]
);
const onWillBuyWithCredits = React.useCallback(
async () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onClickBuyWithCredits();
},
[onClickBuyWithCredits]
);
return (
<div style={styles.videoAndMaterialsContainer}>
{youtubeVideoId && (
<div
style={{
...styles.videoContainer,
maxWidth: windowSize === 'xlarge' ? 960 : 640,
}}
>
<img
alt={`Video for lesson ${courseChapter.title}`}
style={styles.videoThumbnail}
src={`https://i.ytimg.com/vi/${youtubeVideoId}/sddefault.jpg`}
/>
<LockedOverlay />
</div>
)}
<div style={styles.lockedChapterTextContainer}>
<Paper background="medium" style={styles.sideBar}>
<ColumnStackLayout noMargin justifyContent="center">
<Text noMargin size="sub-title">
<Trans>Unlock this lesson to finish the course</Trans>
</Text>
<Text noMargin>
<Trans>
Use your GDevelop credits to purchase lessons in this course;
or get a subscription to get them for free.
</Trans>
</Text>
<ResponsiveLineStackLayout
noMargin
noColumnMargin
forceMobileLayout={windowSize === 'medium'}
>
<RaisedButton
primary
fullWidth
icon={<GoldCompact fontSize="small" />}
disabled={isPurchasing}
label={<Trans>Get a subscription</Trans>}
onClick={() =>
openSubscriptionDialog({
analyticsMetadata: {
reason: 'Unlock course chapter',
recommendedPlanId: 'gdevelop_silver',
},
})
}
/>
{courseChapter.priceInCredits && (
<FlatButton
fullWidth
leftIcon={<Coin fontSize="small" />}
disabled={isPurchasing}
label={
<Trans>Pay {courseChapter.priceInCredits} credits</Trans>
}
onClick={onWillBuyWithCredits}
/>
)}
</ResponsiveLineStackLayout>
{error && <AlertMessage kind="error">{error}</AlertMessage>}
</ColumnStackLayout>
</Paper>
</div>
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onClickBuyWithCredits}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</div>
);
}
);
export default LockedCourseChapterPreview;

View File

@@ -0,0 +1,102 @@
// @flow
import * as React from 'react';
import type {
TextBasedCourseChapterTaskItem as TextBasedCourseChapterTaskItemType,
TextBasedCourseChapterTextItem as TextBasedCourseChapterTextItemType,
TextBasedCourseChapterImageItem as TextBasedCourseChapterImageItemType,
TextBasedCourseChapterVideoItem as TextBasedCourseChapterVideoItemType,
} from '../Utils/GDevelopServices/Asset';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { MarkdownText } from '../UI/MarkdownText';
import ImageWithZoom from '../UI/ImageWithZoom';
import TextBasedCourseChapterTaskItem from './TextBasedCourseChapterTaskItem';
import { ColumnStackLayout } from '../UI/Layout';
import { Column, Line } from '../UI/Grid';
const styles = {
media: {
maxWidth: '80%',
},
};
type Props = {|
onCompleteTask?: (taskIndex: number, completed: boolean) => void,
isTaskCompleted?: (taskIndex: number) => boolean,
items:
| Array<
| TextBasedCourseChapterTaskItemType
| TextBasedCourseChapterTextItemType
| TextBasedCourseChapterImageItemType
| TextBasedCourseChapterVideoItemType
>
| Array<
| TextBasedCourseChapterTextItemType
| TextBasedCourseChapterImageItemType
| TextBasedCourseChapterVideoItemType
>,
|};
const TextBasedCourseChapterItems = ({
items,
onCompleteTask,
isTaskCompleted,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
// $FlowFixMe - map does not recognize time of items.
return (
<Line>
<Column noMargin>
{items.map((item, itemIndex) => {
if (item.type === 'text') {
return (
<MarkdownText
key={itemIndex.toString()}
allowParagraphs
source={item.text}
/>
);
}
if (item.type === 'image') {
return (
<ColumnStackLayout key={itemIndex.toString()} alignItems="center">
<ImageWithZoom width="90%" alt="" src={item.url} />
{item.caption && (
<div style={{ color: gdevelopTheme.text.color.secondary }}>
<MarkdownText source={item.caption} />
</div>
)}
</ColumnStackLayout>
);
}
if (item.type === 'video') {
return (
<ColumnStackLayout key={itemIndex.toString()} alignItems="center">
<video src={item.url} style={styles.media} controls />
{item.caption && (
<div style={{ color: gdevelopTheme.text.color.secondary }}>
<MarkdownText source={item.caption} />
</div>
)}
</ColumnStackLayout>
);
}
if (item.type === 'task' && isTaskCompleted && onCompleteTask) {
const isTaskComplete = isTaskCompleted(itemIndex);
return (
<TextBasedCourseChapterTaskItem
key={itemIndex.toString()}
task={item}
isComplete={isTaskComplete}
onComplete={() => onCompleteTask(itemIndex, !isTaskComplete)}
/>
);
}
return null;
})}
</Column>
</Line>
);
};
export default TextBasedCourseChapterItems;

View File

@@ -0,0 +1,124 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import Divider from '@material-ui/core/Divider';
import type { TextBasedCourseChapterTaskItem as TextBasedCourseChapterTaskItemType } from '../Utils/GDevelopServices/Asset';
import { ColumnStackLayout, LineStackLayout } from '../UI/Layout';
import Checkbox from '../UI/Checkbox';
import Text from '../UI/Text';
import { Line, Spacer } from '../UI/Grid';
import { Accordion, AccordionBody, AccordionHeader } from '../UI/Accordion';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import FlatButton from '../UI/FlatButton';
import ChevronArrowBottom from '../UI/CustomSvgIcons/ChevronArrowBottom';
import ChevronArrowRight from '../UI/CustomSvgIcons/ChevronArrowRight';
import TextBasedCourseChapterItems from './TextBasedCourseChapterItems';
const styles = {
textContainer: { overflow: 'hidden' },
checkboxContainer: { paddingTop: 15 },
image: {
maxWidth: '100%',
},
icon: {
fontSize: 18,
},
stickyTitle: {
position: 'sticky',
top: -1, // If 0, it somehow lets a 1px gap between the parent, letting the user see the text scroll behind.
display: 'flex',
flexDirection: 'column',
zIndex: 2,
},
checkbox: { margin: 0 },
};
type Props = {|
task: TextBasedCourseChapterTaskItemType,
isComplete: boolean,
onComplete: boolean => void,
|};
const TextBasedCourseChapterTaskItem = ({
task,
isComplete,
onComplete,
}: Props) => {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const { isMobile, isLandscape } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<Line>
<ColumnStackLayout noMargin expand>
<ColumnStackLayout noMargin>
<div
style={{
...styles.stickyTitle,
backgroundColor: gdevelopTheme.paper.backgroundColor.dark,
}}
>
<Divider />
<Spacer />
<Line alignItems="center" justifyContent="space-between" noMargin>
<Text size="block-title">{task.title}</Text>
<FlatButton
primary
label={
isOpen ? <Trans>Close task</Trans> : <Trans>Open task</Trans>
}
leftIcon={
isOpen ? (
<ChevronArrowBottom style={styles.icon} />
) : (
<ChevronArrowRight style={styles.icon} />
)
}
onClick={() => setIsOpen(!isOpen)}
/>
</Line>
<Spacer />
<Divider />
</div>
</ColumnStackLayout>
{isOpen && (
<LineStackLayout alignItems="flex-start" noMargin>
<div
style={{
...styles.checkboxContainer,
paddingLeft: isMobile && !isLandscape ? 0 : 20,
}}
>
<Checkbox
checked={isComplete}
onCheck={() => onComplete(!isComplete)}
style={styles.checkbox}
/>
</div>
<ColumnStackLayout expand noMargin noOverflowParent>
<TextBasedCourseChapterItems items={task.items} />
{isOpen && task.answer && (
<Accordion kind="answer" noMargin>
<AccordionHeader>
<Text size="sub-title">
<Trans>Answer</Trans>
</Text>
</AccordionHeader>
<AccordionBody>
<ColumnStackLayout>
<TextBasedCourseChapterItems items={task.answer.items} />
</ColumnStackLayout>
</AccordionBody>
</Accordion>
)}
</ColumnStackLayout>
</LineStackLayout>
)}
</ColumnStackLayout>
</Line>
);
};
export default TextBasedCourseChapterTaskItem;

View File

@@ -0,0 +1,138 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import type {
TextBasedCourseChapter,
CourseChapter,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout';
import Paper from '../UI/Paper';
import RaisedButton from '../UI/RaisedButton';
import { Column, Line } from '../UI/Grid';
import Cloud from '../UI/CustomSvgIcons/Cloud';
import { rankLabel } from '../Utils/Ordinal';
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
import LockedCourseChapterPreview from './LockedCourseChapterPreview';
import CourseChapterTitle from './CourseChapterTitle';
import TextBasedCourseChapterItems from './TextBasedCourseChapterItems';
const styles = {
icon: {
fontSize: 18,
},
videoAndMaterialsContainer: {
display: 'flex',
marginTop: 8,
gap: 8,
alignItems: 'stretch',
flexWrap: 'wrap',
marginBottom: 8,
flex: 1,
minWidth: 0,
},
sideBar: { padding: 16, display: 'flex' },
image: {
maxWidth: '100%',
},
};
type Props = {|
chapterIndex: number,
courseChapter: TextBasedCourseChapter,
onOpenTemplate: (templateId?: string) => void,
onCompleteTask: (
chapterId: string,
taskIndex: number,
completed: boolean
) => void,
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|};
const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
(
{
chapterIndex,
courseChapter,
onOpenTemplate,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
onBuyWithCredits,
},
ref
) => {
return (
<ColumnStackLayout expand noMargin>
<CourseChapterTitle
chapterIndex={chapterIndex}
courseChapter={courseChapter}
getChapterCompletion={getChapterCompletion}
ref={ref}
/>
{courseChapter.isLocked ? (
<LockedCourseChapterPreview
onBuyWithCredits={onBuyWithCredits}
courseChapter={courseChapter}
/>
) : courseChapter.templates.length > 0 ? (
<div style={styles.videoAndMaterialsContainer}>
<ColumnStackLayout noMargin>
<Text size="sub-title" noMargin>
<Trans>Chapter materials</Trans>
</Text>
<Paper background="medium" style={styles.sideBar}>
<ColumnStackLayout noMargin>
{courseChapter.templates.map(template => (
<Line noMargin alignItems="center">
<Text noMargin>{rankLabel[chapterIndex + 1]}</Text>
&nbsp;
<Text noMargin>
<Trans>Chapter</Trans>
</Text>
&nbsp;-&nbsp;
{template.title && (
<Text noMargin>{template.title}&nbsp;</Text>
)}
<Text noMargin>
<Trans>Template</Trans>
</Text>
<Column>
<RaisedButton
primary
icon={<Cloud fontSize="small" />}
label={<Trans>Open template</Trans>}
onClick={() => onOpenTemplate(template.id)}
/>
</Column>
</Line>
))}
</ColumnStackLayout>
</Paper>
</ColumnStackLayout>
</div>
) : null}
<Column>
{!courseChapter.isLocked && (
<TextBasedCourseChapterItems
items={courseChapter.items}
onCompleteTask={(index: number, completed: boolean) =>
onCompleteTask(courseChapter.id, index, completed)
}
isTaskCompleted={(index: number) =>
isTaskCompleted(courseChapter.id, index)
}
/>
)}
</Column>
</ColumnStackLayout>
);
}
);
export default TextBasedCourseChapterView;

View File

@@ -16,9 +16,6 @@ import AlertMessage from '../UI/AlertMessage';
const styles = {
textContainer: { overflow: 'hidden' },
checkboxContainer: { paddingTop: 6 },
image: {
maxWidth: '100%',
},
};
type Props = {|
@@ -28,7 +25,7 @@ type Props = {|
onComplete: boolean => void,
|};
const CourseChapterTaskItem = ({
const VideoBasedCourseChapterTaskItem = ({
courseChapterTask,
isOpen,
isComplete,
@@ -89,7 +86,7 @@ const CourseChapterTaskItem = ({
courseChapterTask.imageUrls &&
courseChapterTask.imageUrls.map(imageUrl => (
<ImageWithZoom
style={styles.image}
width="100%"
key={imageUrl}
alt=""
src={imageUrl}
@@ -121,7 +118,7 @@ const CourseChapterTaskItem = ({
<ColumnStackLayout noMargin noOverflowParent>
{courseChapterTask.answer.imageUrls.map(imageUrl => (
<ImageWithZoom
style={styles.image}
width="100%"
key={imageUrl}
alt=""
src={imageUrl}
@@ -138,4 +135,4 @@ const CourseChapterTaskItem = ({
);
};
export default CourseChapterTaskItem;
export default VideoBasedCourseChapterTaskItem;

View File

@@ -0,0 +1,214 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import type {
VideoBasedCourseChapter,
CourseChapter,
} from '../Utils/GDevelopServices/Asset';
import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout';
import Paper from '../UI/Paper';
import RaisedButton from '../UI/RaisedButton';
import { Line, Spacer } from '../UI/Grid';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import Divider from '@material-ui/core/Divider';
import FlatButton from '../UI/FlatButton';
import ChevronArrowBottom from '../UI/CustomSvgIcons/ChevronArrowBottom';
import ChevronArrowRight from '../UI/CustomSvgIcons/ChevronArrowRight';
import Cloud from '../UI/CustomSvgIcons/Cloud';
import VideoBasedCourseChapterTaskItem from './VideoBasedCourseChapterTaskItem';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
import { rankLabel } from '../Utils/Ordinal';
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
import LockedCourseChapterPreview from './LockedCourseChapterPreview';
import CourseChapterTitle from './CourseChapterTitle';
import { getYoutubeVideoIdFromUrl } from '../Utils/Youtube';
const styles = {
icon: {
fontSize: 18,
},
stickyTitle: {
position: 'sticky',
top: -1, // If 0, it somehow lets a 1px gap between the parent, letting the user see the text scroll behind.
display: 'flex',
flexDirection: 'column',
zIndex: 2,
},
videoAndMaterialsContainer: {
display: 'flex',
marginTop: 8,
gap: 8,
alignItems: 'stretch',
flexWrap: 'wrap',
marginBottom: 8,
flex: 1,
minWidth: 0,
},
videoContainer: {
flex: 2,
minWidth: 300,
display: 'flex',
position: 'relative',
},
videoIFrame: { flex: 1, aspectRatio: '16 / 9' },
sideBar: { padding: 16, display: 'flex' },
};
type Props = {|
chapterIndex: number,
courseChapter: VideoBasedCourseChapter,
onOpenTemplate: () => void,
onCompleteTask: (
chapterId: string,
taskIndex: number,
completed: boolean
) => void,
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|};
const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
(
{
chapterIndex,
courseChapter,
onOpenTemplate,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
onBuyWithCredits,
},
ref
) => {
const {
values: { language },
} = React.useContext(PreferencesContext);
const userLanguage2LetterCode = language.split('_')[0];
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { windowSize } = useResponsiveWindowSize();
const [openTasks, setOpenTasks] = React.useState<boolean>(false);
const youtubeVideoId = getYoutubeVideoIdFromUrl(courseChapter.videoUrl);
return (
<ColumnStackLayout expand noMargin>
<CourseChapterTitle
chapterIndex={chapterIndex}
courseChapter={courseChapter}
getChapterCompletion={getChapterCompletion}
ref={ref}
/>
{courseChapter.isLocked ? (
<LockedCourseChapterPreview
onBuyWithCredits={onBuyWithCredits}
courseChapter={courseChapter}
/>
) : (
<div style={styles.videoAndMaterialsContainer}>
{youtubeVideoId && (
<div
style={{
...styles.videoContainer,
maxWidth: windowSize === 'xlarge' ? 960 : 640,
}}
>
<iframe
title={`Video for lesson ${courseChapter.title}`}
type="text/html"
style={styles.videoIFrame}
src={`https://www.youtube.com/embed/${youtubeVideoId}?cc_load_policy=1&cc_lang_pref=${
// Having another language than `en` as the requested caption language prevents the player from displaying the auto-translated captions.
'en'
}&hl=${userLanguage2LetterCode}`}
frameBorder="0"
/>
</div>
)}
<ColumnStackLayout noMargin expand>
<Text size="sub-title" noMargin>
<Trans>Chapter materials</Trans>
</Text>
<Paper background="medium" style={styles.sideBar}>
<ColumnStackLayout noMargin>
<Line noMargin>
<Text noMargin>{rankLabel[chapterIndex + 1]}</Text>
&nbsp;
<Text noMargin>
<Trans>Chapter</Trans>
</Text>
&nbsp;-&nbsp;
<Text noMargin>
<Trans>Template</Trans>
</Text>
</Line>
<Line noMargin>
<RaisedButton
primary
icon={<Cloud fontSize="small" />}
label={<Trans>Open template</Trans>}
onClick={onOpenTemplate}
/>
</Line>
</ColumnStackLayout>
</Paper>
</ColumnStackLayout>
</div>
)}
{!courseChapter.isLocked && (
<div
style={{
...styles.stickyTitle,
backgroundColor: gdevelopTheme.paper.backgroundColor.dark,
}}
>
<Divider />
<Spacer />
<Line alignItems="center" justifyContent="space-between" noMargin>
<Text size="block-title">
<Trans>Tasks</Trans>
</Text>
<FlatButton
primary
label={
openTasks ? (
<Trans>Close all tasks</Trans>
) : (
<Trans>Open all tasks</Trans>
)
}
leftIcon={
openTasks ? (
<ChevronArrowBottom style={styles.icon} />
) : (
<ChevronArrowRight style={styles.icon} />
)
}
onClick={() => setOpenTasks(!openTasks)}
/>
</Line>
<Spacer />
<Divider />
</div>
)}
{!courseChapter.isLocked &&
courseChapter.tasks.map((item, taskIndex) => (
<VideoBasedCourseChapterTaskItem
courseChapterTask={item}
key={taskIndex.toString()}
isOpen={openTasks}
isComplete={isTaskCompleted(courseChapter.id, taskIndex)}
onComplete={isCompleted =>
onCompleteTask(courseChapter.id, taskIndex, isCompleted)
}
/>
))}
</ColumnStackLayout>
);
}
);
export default VideoBasedCourseChapterView;

View File

@@ -289,6 +289,10 @@ const CreditsPackagesDialog = ({
this guide
</Link>{' '}
to help you decide.
</Trans>{' '}
<Trans>
Follow GDevelop on socials and check your profile to get some
free credits!
</Trans>
</BackgroundText>
</ColumnStackLayout>

View File

@@ -115,7 +115,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select an external layout</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -256,7 +256,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select a key</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -142,7 +142,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select an effect</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -170,7 +170,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select an effect property</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -134,7 +134,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select a layer</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -141,7 +141,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select a mouse button</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -145,7 +145,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select an animation</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -180,7 +180,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select an effect</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -229,7 +229,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select an effect property</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -116,7 +116,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select a scene</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

@@ -129,7 +129,7 @@ export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
leftIcon={<TypeCursorSelect />}
style={style}
primary
label={<Trans>Select a value</Trans>}
label={<Trans>Select</Trans>}
onClick={switchFieldType}
/>
) : (

View File

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

View File

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

View File

@@ -23,11 +23,16 @@ import { type Quota } from '../../../Utils/GDevelopServices/Usage';
import IconButton from '../../../UI/IconButton';
import Like from '../../../UI/CustomSvgIcons/Like';
import Dislike from '../../../UI/CustomSvgIcons/Dislike';
import Copy from '../../../UI/CustomSvgIcons/Copy';
import GDevelopThemeContext from '../../../UI/Theme/GDevelopThemeContext';
import { type MessageDescriptor } from '../../../Utils/i18n/MessageDescriptor.flow';
import Link from '../../../UI/Link';
import { getHelpLink } from '../../../Utils/HelpLink';
import Window from '../../../Utils/Window';
import { DislikeFeedbackDialog } from './DislikeFeedbackDialog';
const TOO_MANY_MESSAGES_WARNING_COUNT = 9;
const TOO_MANY_MESSAGES_ERROR_COUNT = 14;
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
type Props = {
aiRequest: AiRequest | null,
@@ -37,7 +42,8 @@ type Props = {
onSendFeedback: (
aiRequestId: string,
messageIndex: number,
feedback: 'like' | 'dislike'
feedback: 'like' | 'dislike',
reason?: string
) => Promise<void>,
hasOpenedProject: boolean,
@@ -104,6 +110,11 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
const [messageFeedbacks, setMessageFeedbacks] = React.useState({});
const theme = React.useContext(GDevelopThemeContext);
const [
dislikeFeedbackDialogOpenedFor,
setDislikeFeedbackDialogOpenedFor,
] = React.useState(null);
const [newChatPlaceholder] = React.useState(() => {
const newChatPlaceholders: Array<MessageDescriptor> = [
t`How to add a leaderboard?`,
@@ -138,10 +149,14 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
const { isMobile } = useResponsiveWindowSize();
const quotaOrCreditsExplanation = !quota ? null /* User is probably not even logged in. */ : !quota.limitReached ? (
quota.current < 1 || increaseQuotaOffering === 'subscribe' ? null : (
increaseQuotaOffering === 'subscribe' ? (
<Trans>
You still have {quota.max - quota.current} free questions thanks to
your membership.
You still have {quota.max - quota.current} free answers from the AI.
</Trans>
) : (
<Trans>
You still have {quota.max - quota.current} free answers with your
membership (refreshed every month).
</Trans>
)
) : aiRequestPriceInCredits ? (
@@ -200,70 +215,88 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
An error happened when sending your request, please try again.
</Trans>
) : (
quotaOrCreditsExplanation
quotaOrCreditsExplanation || '\u00a0'
)}
</Text>
);
if (!aiRequest) {
const disclaimer = errorOrQuotaOrCreditsExplanation || (
<Text size="body2" color="secondary">
<Trans>
The AI will answer according to your game project. Always verify AI
answers before applying them.
</Trans>
</Text>
);
return (
<div className={classes.newChatContainer}>
<Line noMargin justifyContent="center">
<RobotIcon rotating size={40} />
</Line>
<Column noMargin alignItems="center">
<Text size="bold-title">
<Trans>What do you want to make?</Trans>
<ColumnStackLayout justifyContent="center" expand>
<Line noMargin justifyContent="center">
<RobotIcon rotating size={40} />
</Line>
<Column noMargin alignItems="center">
<Text size="bold-title">
<Trans>What do you want to make?</Trans>
</Text>
</Column>
<Column noMargin alignItems="stretch" justifyContent="stretch">
<CompactTextAreaField
maxLength={6000}
value={userRequestText}
disabled={isLaunchingAiRequest}
onChange={userRequestText =>
setUserRequestText(userRequestText)
}
placeholder={newChatPlaceholder}
rows={5}
/>
</Column>
<Line noMargin>
<ResponsiveLineStackLayout
noMargin
alignItems="flex-start"
justifyContent="space-between"
expand
>
{!isMobile && errorOrQuotaOrCreditsExplanation}
<Line noMargin justifyContent="flex-end">
<LeftLoader reserveSpace isLoading={isLaunchingAiRequest}>
<RaisedButton
color="primary"
label={<Trans>Send</Trans>}
style={{ flexShrink: 0 }}
disabled={isLaunchingAiRequest}
onClick={() => {
onSendUserRequest(userRequestText);
}}
/>
</LeftLoader>
</Line>
{isMobile && errorOrQuotaOrCreditsExplanation}
</ResponsiveLineStackLayout>
</Line>
{subscriptionBanner}
</ColumnStackLayout>
<Column justifyContent="center" noMargin>
<Text size="body-small" color="secondary" align="center" noMargin>
<Trans>
The AI is experimental and still being improved.{' '}
<Link
href={getHelpLink('/interface/ask-ai')}
color="secondary"
onClick={() =>
Window.openExternalURL(getHelpLink('/interface/ask-ai'))
}
>
It has access to your game objects but not events.
</Link>
</Trans>
</Text>
<Text size="body-small" color="secondary" align="center" noMargin>
<Trans>Answers may have mistakes: always verify them.</Trans>
</Text>
</Column>
<Column noMargin alignItems="stretch" justifyContent="stretch">
<CompactTextAreaField
maxLength={6000}
value={userRequestText}
disabled={isLaunchingAiRequest}
onChange={userRequestText => setUserRequestText(userRequestText)}
placeholder={newChatPlaceholder}
rows={5}
/>
</Column>
<Line noMargin>
<ResponsiveLineStackLayout
noMargin
alignItems="flex-start"
justifyContent="space-between"
expand
>
{!isMobile && disclaimer}
<Line noMargin justifyContent="flex-end">
<LeftLoader reserveSpace isLoading={isLaunchingAiRequest}>
<RaisedButton
color="primary"
label={<Trans>Send</Trans>}
style={{ flexShrink: 0 }}
disabled={isLaunchingAiRequest}
onClick={() => {
onSendUserRequest(userRequestText);
}}
/>
</LeftLoader>
</Line>
{isMobile && disclaimer}
</ResponsiveLineStackLayout>
</Line>
{subscriptionBanner}
</div>
);
}
const userMessagesCount = aiRequest.output.filter(
message => message.role === 'user'
).length;
return (
<ColumnStackLayout
expand
@@ -301,6 +334,17 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
role="assistant"
feedbackButtons={
<div className={classes.feedbackButtonsContainer}>
<IconButton
size="small"
tooltip={t`Copy`}
onClick={() => {
navigator.clipboard.writeText(
messageContent.text
);
}}
>
<Copy fontSize="small" />
</IconButton>
<IconButton
size="small"
tooltip={t`This was helpful`}
@@ -333,11 +377,10 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
...messageFeedbacks,
[feedbackKey]: 'dislike',
});
onSendFeedback(
aiRequest.id,
setDislikeFeedbackDialogOpenedFor({
aiRequestId: aiRequest.id,
messageIndex,
'dislike'
);
});
}}
>
<Dislike
@@ -381,8 +424,8 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
<Line justifyContent="flex-start">
<AlertMessage kind="error">
<Trans>
The AI encountered an error while handling your request. Try
again later.
The AI encountered an error while handling your request - this
was request was not counted in your AI usage. Try again later.
</Trans>
</AlertMessage>
</Line>
@@ -398,10 +441,10 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
</Line>
) : null}
</ScrollView>
{aiRequest.output.length >= TOO_MANY_MESSAGES_WARNING_COUNT ? (
{userMessagesCount >= TOO_MANY_USER_MESSAGES_WARNING_COUNT ? (
<AlertMessage
kind={
aiRequest.output.length >= TOO_MANY_MESSAGES_ERROR_COUNT
userMessagesCount >= TOO_MANY_USER_MESSAGES_ERROR_COUNT
? 'error'
: 'warning'
}
@@ -446,6 +489,20 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
{isMobile && errorOrQuotaOrCreditsExplanation}
</ResponsiveLineStackLayout>
</Column>
{dislikeFeedbackDialogOpenedFor && (
<DislikeFeedbackDialog
open
onClose={() => setDislikeFeedbackDialogOpenedFor(null)}
onSendFeedback={(reason: string) => {
onSendFeedback(
dislikeFeedbackDialogOpenedFor.aiRequestId,
dislikeFeedbackDialogOpenedFor.messageIndex,
'dislike',
reason
);
}}
/>
)}
</ColumnStackLayout>
);
}

View File

@@ -51,11 +51,7 @@
display: flex;
flex-direction: column;
flex: 1;
align-items: stretch;
justify-content: center;
gap: 8px;
margin-left: 8px;
margin-right: 8px;
animation: new-chat-appear 0.5s;
}

View File

@@ -9,8 +9,10 @@ a.conceptLink {
}
a.noValidLink {
color: inherit;
cursor: default;
text-decoration: none;
text-decoration: underline;
text-decoration-style: dotted;
}
.conceptLink span {

View File

@@ -151,10 +151,10 @@ export const ConceptLink = ({
return (
<Tooltip
title={[
<Text>
<Text color="inherit">
<b>{getConceptKindLabel(conceptMetadata.kind)}</b>
</Text>,
conceptParent ? <Text>{conceptParent}</Text> : null,
conceptParent ? <Text color="inherit">{conceptParent}</Text> : null,
<MarkdownText source={conceptMetadata.description} />,
]}
placement="bottom"

View File

@@ -0,0 +1,110 @@
// @flow
import * as React from 'react';
import { ColumnStackLayout } from '../../../UI/Layout';
import Text from '../../../UI/Text';
import { Trans } from '@lingui/macro';
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
import Radio from '@material-ui/core/Radio';
import RadioGroup from '@material-ui/core/RadioGroup';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import FlatButton from '../../../UI/FlatButton';
type DislikeFeedbackDialogProps = {|
open: boolean,
onClose: () => void,
onSendFeedback: (reason: string) => void,
|};
export const DislikeFeedbackDialog = ({
open,
onClose,
onSendFeedback,
}: DislikeFeedbackDialogProps) => {
const [selectedReason, setSelectedReason] = React.useState<?string>(null);
const handleChange = (event: { target: { value: string } }) => {
setSelectedReason(event.target.value);
};
const handleSendFeedback = () => {
if (selectedReason) {
onSendFeedback(selectedReason);
onClose();
}
};
return (
<Dialog
title={<Trans>What could be improved?</Trans>}
actions={[
<FlatButton
key="cancel"
label={<Trans>Cancel</Trans>}
onClick={onClose}
/>,
<DialogPrimaryButton
key="send"
primary
label={<Trans>Send feedback</Trans>}
onClick={handleSendFeedback}
disabled={!selectedReason}
/>,
]}
open={open}
onRequestClose={onClose}
maxWidth="sm"
>
<ColumnStackLayout noMargin>
<Text>
<Trans>
Help us improve by telling us what was wrong with the answer:
</Trans>
</Text>
<RadioGroup value={selectedReason || ''} onChange={handleChange}>
<FormControlLabel
value="not-in-my-language"
control={<Radio color="secondary" />}
label={<Trans>The answer is not in my language</Trans>}
/>
<FormControlLabel
value="non-existing-things"
control={<Radio color="secondary" />}
label={
<Trans>Some things in the answer don't exist in GDevelop</Trans>
}
/>
<FormControlLabel
value="not-as-good-as-it-could-be"
control={<Radio color="secondary" />}
label={<Trans>The answer is not as good as it could be</Trans>}
/>
<FormControlLabel
value="very-wrong-answer"
control={<Radio color="secondary" />}
label={<Trans>The answer is entirely wrong</Trans>}
/>
<FormControlLabel
value="out-of-scope"
control={<Radio color="secondary" />}
label={<Trans>The answer is out of scope for GDevelop</Trans>}
/>
<FormControlLabel
value="too-short"
control={<Radio color="secondary" />}
label={<Trans>The answer is too short</Trans>}
/>
<FormControlLabel
value="too-long"
control={<Radio color="secondary" />}
label={<Trans>The answer is too long</Trans>}
/>
<FormControlLabel
value="other"
control={<Radio color="secondary" />}
label={<Trans>Other reason</Trans>}
/>
</RadioGroup>
</ColumnStackLayout>
</Dialog>
);
};

View File

@@ -23,6 +23,7 @@ export const Toolbar = ({
<ToolbarGroup firstChild>
<IconButton
size="small"
color="default"
tooltip={t`View history`}
onClick={onOpenHistory}
>

View File

@@ -244,7 +244,7 @@ export const AskAi = React.memo<Props>(
);
const onSendFeedback = React.useCallback(
async (aiRequestId, messageIndex, feedback) => {
async (aiRequestId, messageIndex, feedback, reason) => {
if (!profile) return;
try {
await retryIfFailed({ times: 2 }, () =>
@@ -253,6 +253,7 @@ export const AskAi = React.memo<Props>(
aiRequestId,
messageIndex,
feedback,
reason,
})
);
} catch (error) {

View File

@@ -131,7 +131,10 @@ export type RenderEditorContainerProps = {|
isQuickCustomization?: boolean
) => Promise<void>,
onOpenTemplateFromTutorial: (tutorialId: string) => Promise<void>,
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
onOpenTemplateFromCourseChapter: (
CourseChapter,
templateId?: string
) => Promise<void>,
onOpenPrivateGameTemplateListingData: PrivateGameTemplateListingData => void,
// Project save

View File

@@ -30,6 +30,7 @@ const useStylesForWidget = ({
|}) =>
makeStyles(theme => {
const rootStyles = {
boxSizing: 'border-box',
border: `1px solid ${theme.palette.text.primary}`,
borderBottom: `6px solid ${theme.palette.text.primary}`,
transition: 'background-color 100ms ease',

View File

@@ -1,185 +0,0 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import { I18n as I18nType } from '@lingui/core';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import { Column, Line } from '../../../../UI/Grid';
import Paper from '../../../../UI/Paper';
import {
ColumnStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import Text from '../../../../UI/Text';
import CheckCircle from '../../../../UI/CustomSvgIcons/CheckCircle';
import RaisedButton from '../../../../UI/RaisedButton';
import Window from '../../../../Utils/Window';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
const styles = {
bulletIcon: { width: 20, height: 20, marginRight: 10 },
bulletText: { flex: 1 },
planRecommendationThumbnail: { maxWidth: 350, flex: 1 },
planRecommendationContainer: { borderRadius: 8, maxWidth: 850, padding: 8 },
};
const planImages = {
individual: {
path: 'res/plan-individual.svg',
alt: t`Red hero taking care of their diamond`,
},
education: {
path: 'res/plan-education.svg',
alt: t`Red hero sharing knowledge with pink cloud students`,
},
professional: {
path: 'res/plan-professional.svg',
alt: t`Red and Green heroes running side by side carrying their diamonds`,
},
};
const planDetailsById = {
silver: {
title: <Trans>GDevelop's Silver plan</Trans>,
description: (
<Trans>Unlock GDevelop's features to build more and faster.</Trans>
),
image: planImages.individual,
link: 'https://gdevelop.io/pricing/individual',
},
gold: {
title: <Trans>GDevelop's Gold plan</Trans>,
description: (
<Trans>Unlock GDevelop's features to build more and faster.</Trans>
),
image: planImages.individual,
link: 'https://gdevelop.io/pricing/individual',
},
education: {
title: <Trans>GDevelop's Education plan</Trans>,
description: (
<Trans>
For universities, extra curricular classes and summer camps.
</Trans>
),
image: planImages.education,
link: 'https://gdevelop.io/pricing/education',
},
startup: {
title: <Trans>GDevelop's Startup plan</Trans>,
description: (
<Trans>
Get the most out of GDevelop and get your games out in no time.
</Trans>
),
image: planImages.professional,
link: 'https://gdevelop.io/pricing/business',
},
business: {
title: <Trans>GDevelop's Business plan</Trans>,
description: (
<Trans>
Get the most out of GDevelop and get your games out in no time.
</Trans>
),
image: planImages.professional,
link: 'https://gdevelop.io/pricing/business',
},
};
const PlanRecommendationRow = ({
recommendationPlanId,
subscriptionPlansWithPricingSystems,
i18n,
}: {|
recommendationPlanId: string,
subscriptionPlansWithPricingSystems: SubscriptionPlanWithPricingSystems[],
i18n: I18nType,
|}) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { isMobile } = useResponsiveWindowSize();
const planToUse =
recommendationPlanId === 'silver'
? 'gdevelop_silver'
: recommendationPlanId === 'gold'
? 'gdevelop_gold'
: recommendationPlanId === 'education'
? 'gdevelop_education'
: recommendationPlanId === 'startup' ||
recommendationPlanId === 'business'
? 'gdevelop_startup'
: null;
if (!planToUse) return null;
const plan = subscriptionPlansWithPricingSystems.find(
plan => plan.id === planToUse
);
if (!plan) return null;
const planDetails = planDetailsById[recommendationPlanId];
return (
<Line justifyContent="center">
<Paper
background="dark"
style={{
...styles.planRecommendationContainer,
border: `1px solid ${gdevelopTheme.palette.secondary}`,
}}
>
<ResponsiveLineStackLayout noColumnMargin noMargin>
<img
src={planDetails.image.path}
alt={i18n._(planDetails.image.alt)}
style={styles.planRecommendationThumbnail}
/>
<Line expand>
<ColumnStackLayout>
<Text
noMargin
align={isMobile ? 'center' : 'left'}
size="section-title"
>
{planDetails.title}
</Text>
<Text align={isMobile ? 'center' : 'left'}>
{planDetails.description}
</Text>
<div style={{ padding: `0 20px` }}>
<ColumnStackLayout noMargin>
{plan.bulletPointsByLocale.map(
(bulletPointByLocale, index) => (
<Column key={index} expand noMargin>
<Line noMargin alignItems="center">
<CheckCircle
style={{
...styles.bulletIcon,
color: gdevelopTheme.message.valid,
}}
/>
<Text style={styles.bulletText} size="body2" noMargin>
{selectMessageByLocale(i18n, bulletPointByLocale)}
</Text>
</Line>
</Column>
)
)}
</ColumnStackLayout>
</div>
<Column noMargin>
<RaisedButton
primary
label={<Trans>Learn More</Trans>}
onClick={() => Window.openExternalURL(planDetails.link)}
/>
</Column>
</ColumnStackLayout>
</Line>
</ResponsiveLineStackLayout>
</Paper>
</Line>
);
};
export default PlanRecommendationRow;

View File

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

View File

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

View File

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

View File

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

View File

@@ -247,7 +247,7 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{guidedLessonCards.map((item, index) => (
{guidedLessonCards.map(item => (
<GridListTile key={item.id}>
<InAppTutorialPhaseCard
title={item.title}

View File

@@ -0,0 +1,199 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import Text from '../../../../UI/Text';
import { CardWidget } from '../CardWidget';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import ColoredLinearProgress from '../../../../UI/ColoredLinearProgress';
import { Trans } from '@lingui/macro';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { type CourseCompletion } from '../UseCourses';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import Skeleton from '@material-ui/lab/Skeleton';
const styles = {
cardTextContainer: {
flex: 1,
display: 'flex',
justifyContent: 'center',
padding: '8px 16px 8px 16px',
},
image: { width: '100%' },
specializationDot: {
width: 8,
height: 8,
borderRadius: '50%',
flexShrink: 0,
},
imageContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
skeletonImageContainer: {
display: 'flex',
alignItems: 'stretch',
aspectRatio: '16 / 9',
flexDirection: 'column',
flex: 1,
},
newLabel: {
position: 'absolute',
top: 7,
left: 9,
borderRadius: 4,
backgroundColor: '#F03F18',
fontWeight: 'bold',
color: 'white',
padding: '2px 6px',
},
};
const specializationLabels = {
'game-development': <Trans>Game Development specialization</Trans>,
'interaction-design': <Trans>Interaction Design specialization</Trans>,
};
const specializationColors = {
'game-development': '#5CB0FF',
'interaction-design': '#CAC84E',
};
type Props = {|
completion: ?CourseCompletion,
course: ?Course,
onClick?: () => void,
|};
const CourseCard = ({ completion, course, onClick }: Props) => {
return (
<I18n>
{({ i18n }) => (
<CardWidget onClick={onClick} size={'large'}>
{course && onClick ? (
<Column expand noMargin noOverflowParent>
<div style={styles.imageContainer}>
<img
src={selectMessageByLocale(i18n, course.imageUrlByLocale)}
style={styles.image}
alt=""
/>
{course.newUntil && course.newUntil > Date.now() && (
<div style={styles.newLabel}>
<Text color="inherit" noMargin displayInlineAsSpan>
<Trans>New</Trans>
</Text>
</div>
)}
</div>
<div style={styles.cardTextContainer}>
<ColumnStackLayout
noMargin
expand
justifyContent="space-between"
useFullHeight
noOverflowParent
>
<ColumnStackLayout
noMargin
expand
justifyContent="flex-start"
useFullHeight
noOverflowParent
>
<LineStackLayout alignItems="center" noMargin>
<span
style={{
...styles.specializationDot,
backgroundColor:
specializationColors[course.specializationId],
}}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
style={textEllipsisStyle}
>
{specializationLabels[course.specializationId]}
</Text>
</LineStackLayout>
{completion && (
<LineStackLayout alignItems="center" noMargin>
<ColoredLinearProgress
value={
(completion.completedChapters /
completion.chapters) *
100
}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
>
{completion.completedChapters}/{completion.chapters}
</Text>
</LineStackLayout>
)}
<Text
size="sub-title"
noMargin
color="primary"
align="left"
>
{selectMessageByLocale(i18n, course.titleByLocale)}
</Text>
<Text noMargin color="secondary" align="left">
{selectMessageByLocale(
i18n,
course.shortDescriptionByLocale
)}
</Text>
</ColumnStackLayout>
<Line justifyContent="space-between">
<Text size="body-small" noMargin color="secondary">
{selectMessageByLocale(i18n, course.levelByLocale)}
</Text>
<Text size="body-small" noMargin color="secondary">
{course.durationInWeeks === 1 ? (
<Trans>1 week</Trans>
) : (
<Trans>{course.durationInWeeks} weeks</Trans>
)}
</Text>
</Line>
</ColumnStackLayout>
</div>
</Column>
) : (
<Column noMargin expand>
<div style={styles.skeletonImageContainer}>
<Skeleton variant="rect" height="100%" />
</div>
<Line expand>
<Column expand>
<Skeleton height={20} />
<Skeleton height={20} />
<Skeleton height={30} />
<Skeleton height={50} />
<Spacer />
<Skeleton height={15} />
<Spacer />
</Column>
</Line>
</Column>
)}
</CardWidget>
)}
</I18n>
);
};
export default CourseCard;

View File

@@ -8,7 +8,8 @@ import {
type Course,
} from '../../../../Utils/GDevelopServices/Asset';
import SectionContainer from '../SectionContainer';
import CourseChapterView from '../../../../Course/CourseChapterView';
import VideoBasedCourseChapterView from '../../../../Course/VideoBasedCourseChapterView';
import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapterView';
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
@@ -83,7 +84,10 @@ const alertMessageKey = 'course-subtitles-in-user-language';
type Props = {|
course: Course,
courseChapters: CourseChapter[],
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
onOpenTemplateFromCourseChapter: (
CourseChapter,
templateId?: string
) => Promise<void>,
onBack: () => void,
onCompleteTask: (
chapterId: string,
@@ -144,9 +148,9 @@ const CourseSection = ({
>(new Array(courseChapters.length));
const [activeChapterId, setActiveChapterId] = React.useState<?string>(null);
const subtitleHint = allAlertMessages.find(
message => message.key === alertMessageKey
);
const subtitleHint = courseChapters.some(chapter => 'videoUrl' in chapter) // Display hint only if there are some video-based chapters.
? allAlertMessages.find(message => message.key === alertMessageKey)
: null;
const tableOfContent = courseChapters.map((chapter, chapterIndex) => {
const chapterCompletion = getChapterCompletion(chapter.id);
@@ -298,28 +302,52 @@ const CourseSection = ({
</AlertMessage>
</Line>
)}
{courseChapters.map((chapter, index) => (
<CourseChapterView
chapterIndex={index}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
))}
{courseChapters.map((chapter: CourseChapter, index) =>
chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
) : (
<TextBasedCourseChapterView
chapterIndex={index}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
onOpenTemplateFromCourseChapter(chapter, templateId);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
)
)}
<div style={styles.footer} />
</Column>
{isMobile && !isLandscape ? null : (

View File

@@ -43,6 +43,8 @@ import Help from '../../../../UI/CustomSvgIcons/Help';
import AnyQuestionDialog from '../AnyQuestionDialog';
import Paper from '../../../../UI/Paper';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import CourseCard from './CourseCard';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
const getColumnsFromWindowSize = (
windowSize: WindowSizeType,
@@ -142,10 +144,13 @@ type Props = {|
onSelectCategory: (TutorialCategory | null) => void,
tutorials: Array<Tutorial>,
selectInAppTutorial: (tutorialId: string) => void,
course: ?Course,
courseChapters: ?(CourseChapter[]),
getCourseCompletion: () => CourseCompletion | null,
previewedCourse: ?Course,
courses: ?(Course[]),
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string | null) => void,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
|};
@@ -155,8 +160,10 @@ const MainPage = ({
onSelectCategory,
tutorials,
selectInAppTutorial,
course,
courseChapters,
previewedCourse,
courses,
previewedCourseChapters,
onSelectCourse,
getCourseCompletion,
getCourseChapterCompletion,
}: Props) => {
@@ -164,6 +171,10 @@ const MainPage = ({
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
const {
palette: { type: paletteType },
} = React.useContext(GDevelopThemeContext);
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
false
);
@@ -207,17 +218,68 @@ const MainPage = ({
].filter(Boolean);
return (
<SectionContainer>
<SectionContainer
title={<Trans>Your learning journey starts here</Trans>}
customPaperStyle={{
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'top',
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
paletteType === 'dark'
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
}`,
}}
>
<SectionRow>
{!!course && !!courseChapters && (
<CoursePreviewBanner
course={course}
courseChapters={courseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => onSelectCategory('course')}
/>
)}
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
if (!previewedCourse) return;
onSelectCourse(previewedCourse.id);
onSelectCategory('course');
}}
/>
</SectionRow>
<SectionRow>
<Text size="title">
<Trans>GameDev official specialization courses</Trans>
</Text>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses
? courses.map(course => {
const completion = getCourseCompletion(course.id);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
onSelectCategory('course');
}}
/>
</GridListTile>
);
})
: new Array(2).fill(0).map((_, index) => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard course={null} completion={null} />
</GridListTile>
))}
</GridList>
</Line>
</SectionRow>
<SectionRow>
<Line justifyContent="space-between" noMargin alignItems="center">

View File

@@ -131,7 +131,11 @@ type Props = {|
onSelectCategory: (TutorialCategory | null) => void,
onOpenTemplateFromTutorial: string => Promise<void>,
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
previewedCourse: ?Course,
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string | null) => void,
course: ?Course,
courses: ?(Course[]),
courseChapters: ?(CourseChapter[]),
onCompleteCourseTask: (
chapterId: string,
@@ -140,9 +144,10 @@ type Props = {|
) => void,
isCourseTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
getCourseCompletion: () => CourseCompletion | null,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
|};
@@ -153,8 +158,12 @@ const LearnSection = ({
onSelectCategory,
onOpenTemplateFromTutorial,
onOpenTemplateFromCourseChapter,
previewedCourse,
previewedCourseChapters,
onSelectCourse,
course,
courseChapters,
courses,
onCompleteCourseTask,
isCourseTaskCompleted,
getCourseChapterCompletion,
@@ -179,12 +188,17 @@ const LearnSection = ({
<CourseSection
course={course}
courseChapters={courseChapters}
onBack={() => onSelectCategory(null)}
onBack={() => {
onSelectCategory(null);
onSelectCourse(null);
}}
onOpenTemplateFromCourseChapter={onOpenTemplateFromCourseChapter}
onCompleteTask={onCompleteCourseTask}
isTaskCompleted={isCourseTaskCompleted}
getChapterCompletion={getCourseChapterCompletion}
getCourseCompletion={getCourseCompletion}
getChapterCompletion={(chapterId: string) =>
getCourseChapterCompletion(course.id, chapterId)
}
getCourseCompletion={() => getCourseCompletion(course.id)}
onBuyCourseChapterWithCredits={onBuyCourseChapterWithCredits}
/>
);
@@ -210,8 +224,10 @@ const LearnSection = ({
onSelectCategory={onSelectCategory}
tutorials={tutorials}
selectInAppTutorial={selectInAppTutorial}
course={course}
courseChapters={courseChapters}
courses={courses}
onSelectCourse={onSelectCourse}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
/>

View File

@@ -61,6 +61,7 @@ type Props = {|
titleAdornment?: React.Node,
titleAction?: React.Node,
subtitleText?: React.Node,
customPaperStyle?: Object,
renderSubtitle?: () => React.Node,
backAction?: () => void,
flexBody?: boolean,
@@ -78,6 +79,7 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
titleAdornment,
titleAction,
subtitleText,
customPaperStyle,
renderSubtitle,
backAction,
flexBody,
@@ -110,6 +112,7 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
display: flexBody ? 'flex' : 'block',
...containerStyle,
...scrollStyle,
...customPaperStyle,
};
const childrenContainerStyle = {
...styles.childrenContainer,
@@ -124,7 +127,7 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
const shouldHideAnnouncements =
!!authenticatedUser.limits &&
!!authenticatedUser.limits.capabilities.classrooms &&
!!authenticatedUser.limits.capabilities.classrooms.hidePlayTab;
authenticatedUser.limits.capabilities.classrooms.hideAnnouncements;
return (
<Column expand useFullHeight noMargin>

View File

@@ -58,6 +58,13 @@ const useCourses = () => {
[getAuthorizationHeader]
);
const [selectedCourseId, setSelectedCourseId] = React.useState<string | null>(
null
);
const [userProgressByCourseId, setUserProgressByCourseId] = React.useState<{|
[courseId: string]: ?UserCourseProgress,
|}>({});
const [
userCourseProgress,
setUserCourseProgress,
@@ -71,10 +78,9 @@ const useCourses = () => {
false
);
const [
courseChapters,
setCourseChapters,
] = React.useState<?(CourseChapter[])>(null);
const [chaptersByCourseId, setChaptersByCourseId] = React.useState<{|
[courseId: string]: CourseChapter[],
|}>({});
const userId = profile ? profile.id : null;
const fetchCourses = React.useCallback(async () => {
@@ -82,7 +88,18 @@ const useCourses = () => {
setCourses(fetchedCourses);
}, []);
const selectedCourse = courses ? courses[0] : null;
const onSelectCourse = React.useCallback(
(courseId: string | null) => {
if (!courseId) {
setUserCourseProgressImmediately(null);
} else {
const userProgress = userProgressByCourseId[courseId];
setUserCourseProgressImmediately(userProgress || null);
}
setSelectedCourseId(courseId);
},
[userProgressByCourseId, setUserCourseProgressImmediately]
);
const fetchCourseChapters = React.useCallback(
async (courseId: string) => {
@@ -106,40 +123,31 @@ const useCourses = () => {
}
})(),
]);
setUserCourseProgressImmediately(userProgress);
setCourseChapters(fetchedChapters);
setAreChaptersReady(true);
setUserProgressByCourseId(currentProgressByCourseId => ({
...currentProgressByCourseId,
[courseId]: userProgress,
}));
setChaptersByCourseId(currentChaptersByCourseId => ({
...currentChaptersByCourseId,
[courseId]: fetchedChapters,
}));
} catch (error) {
console.error('An error occurred while fetching courses:', error);
console.error(
`An error occurred while fetching chapters for course ${courseId}:`,
error
);
}
},
// A subscription change will change the displayed chapters sent by the backend.
// So the user subscription is added as a dependency to make sure the chapters are
// up to date with the user subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
getAuthorizationHeader,
userId,
subscription,
setUserCourseProgressImmediately,
userLanguage2LetterCode,
]
);
React.useEffect(
() => {
if (!selectedCourse) {
setCourseChapters(null);
return;
}
fetchCourseChapters(selectedCourse.id);
},
[selectedCourse, fetchCourseChapters]
[getAuthorizationHeader, userId, subscription, userLanguage2LetterCode]
);
const onCompleteTask = React.useCallback(
(chapterId: string, taskIndex: number, completed: boolean) => {
if (!selectedCourse) return;
if (!selectedCourseId) return;
if (!userId) {
onOpenLoginDialog();
return;
@@ -148,7 +156,7 @@ const useCourses = () => {
const newUserCourseProgress: UserCourseProgress = userCourseProgress
? { ...userCourseProgress }
: {
courseId: selectedCourse.id,
courseId: selectedCourseId,
userId,
progress: [],
};
@@ -176,11 +184,15 @@ const useCourses = () => {
}
}
setUserCourseProgress(newUserCourseProgress);
setUserProgressByCourseId(currentUserProgressByCourseId => ({
...currentUserProgressByCourseId,
[selectedCourseId]: newUserCourseProgress,
}));
},
[
userCourseProgress,
userId,
selectedCourse,
selectedCourseId,
setUserCourseProgress,
onOpenLoginDialog,
]
@@ -201,17 +213,21 @@ const useCourses = () => {
);
const getChapterCompletion = React.useCallback(
(chapterId: string): CourseChapterCompletion | null => {
if (!courseChapters) return null;
(courseId: string, chapterId: string): CourseChapterCompletion | null => {
const chapters = chaptersByCourseId[courseId];
if (!chapters) return null;
const chapter = courseChapters.find(chapter => chapter.id === chapterId);
const chapter = chapters.find(chapter => chapter.id === chapterId);
if (!chapter || chapter.isLocked) return null;
const tasksCount = chapter.tasks.length;
const tasksCount = chapter.tasks
? chapter.tasks.length
: chapter.items.filter(item => item.type === 'task').length;
if (!userCourseProgress) return { completedTasks: 0, tasks: tasksCount };
const courseProgress = userProgressByCourseId[courseId];
if (!courseProgress) return { completedTasks: 0, tasks: tasksCount };
const chapterProgress = userCourseProgress.progress.find(
const chapterProgress = courseProgress.progress.find(
chapterProgress => chapterProgress.chapterId === chapterId
);
if (!chapterProgress) return { completedTasks: 0, tasks: tasksCount };
@@ -221,33 +237,44 @@ const useCourses = () => {
tasks: tasksCount,
};
},
[userCourseProgress, courseChapters]
[userProgressByCourseId, chaptersByCourseId]
);
const getCourseCompletion = React.useCallback(
(): CourseCompletion | null => {
if (!courseChapters || !selectedCourse) return null;
const chaptersCount = selectedCourse.chaptersTargetCount;
if (!userCourseProgress)
(courseId: string): CourseCompletion | null => {
if (!courses) return null;
const course = courses.find(course => course.id === courseId);
if (!course) return null;
const chapters = chaptersByCourseId[courseId];
if (!chapters) return null;
const chaptersCount = course.chaptersTargetCount;
const courseProgress = userProgressByCourseId[courseId];
if (!courseProgress)
return { percentage: 0, completedChapters: 0, chapters: chaptersCount };
let completion = 0;
let completedChapters = 0;
const chapterProportion = 1 / chaptersCount;
courseChapters.forEach(chapter => {
chapters.forEach(chapter => {
if (chapter.isLocked) return;
const chapterProgress = userCourseProgress.progress.find(
const chapterProgress = courseProgress.progress.find(
chapterProgress => chapterProgress.chapterId === chapter.id
);
if (!chapterProgress) return;
const tasksCount = chapter.tasks
? chapter.tasks.length
: chapter.items.filter(item => item.type === 'task').length;
const isChapterCompleted =
chapterProgress.completedTasks.length >= chapter.tasks.length;
chapterProgress.completedTasks.length >= tasksCount;
if (isChapterCompleted) completedChapters++;
completion +=
(chapterProgress.completedTasks.length / chapter.tasks.length) *
(chapterProgress.completedTasks.length / tasksCount) *
chapterProportion;
});
@@ -257,7 +284,7 @@ const useCourses = () => {
completedChapters,
};
},
[userCourseProgress, courseChapters, selectedCourse]
[userProgressByCourseId, chaptersByCourseId, courses]
);
const onBuyCourseChapterWithCredits = React.useCallback(
@@ -319,7 +346,7 @@ const useCourses = () => {
userId,
password,
});
if (selectedCourse) await fetchCourseChapters(selectedCourse.id);
if (selectedCourseId) await fetchCourseChapters(selectedCourseId);
},
successMessage: <Trans>🎉 You can now follow your new chapter!</Trans>,
});
@@ -333,21 +360,37 @@ const useCourses = () => {
getAuthorizationHeader,
onOpenLoginDialog,
fetchCourseChapters,
selectedCourse,
selectedCourseId,
]
);
React.useEffect(
() => {
fetchCourses();
(async () => {
if (courses) {
await Promise.all(
courses.map(course => fetchCourseChapters(course.id))
);
setAreChaptersReady(true);
}
})();
},
[fetchCourses]
// (Re)fetch course chapters when courses are defined and when fetchCourseChapters
// changes (see its dependencies).
[courses, fetchCourseChapters]
);
const selectedCourse =
selectedCourseId && courses && areChaptersReady
? courses.find(course => course.id === selectedCourseId) || null
: null;
return {
courses,
courseChapters,
fetchCourses,
onSelectCourse,
selectedCourse,
courseChaptersByCourseId: chaptersByCourseId,
areChaptersReady,
onCompleteTask,
isTaskCompleted,

View File

@@ -8,7 +8,6 @@ import {
type FileMetadata,
type StorageProvider,
} from '../../../ProjectsStorage';
import GetStartedSection from './GetStartedSection';
import LearnSection from './LearnSection';
import PlaySection from './PlaySection';
import CreateSection from './CreateSection';
@@ -27,13 +26,6 @@ import TeamProvider from '../../../Profile/Team/TeamProvider';
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
import { type PrivateGameTemplateListingData } from '../../../Utils/GDevelopServices/Shop';
import { PrivateGameTemplateStoreContext } from '../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import PreferencesContext from '../../Preferences/PreferencesContext';
import useSubscriptionPlans from '../../../Utils/UseSubscriptionPlans';
import { incrementGetStartedSectionViewCount } from '../../../Utils/Analytics/LocalStats';
import {
sendUserSurveyHidden,
sendUserSurveyStarted,
} from '../../../Utils/Analytics/EventSender';
import RouterContext, { type RouteArguments } from '../../RouterContext';
import { type GameDetailsTab } from '../../../GameDashboard';
import { canUseClassroomFeature } from '../../../Utils/GDevelopServices/Usage';
@@ -45,6 +37,8 @@ import { type GamesList } from '../../../GameDashboard/UseGamesList';
import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFrame';
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
import useCourses from './UseCourses';
import { getProgramOpeningCount } from '../../../Utils/Analytics/LocalStats';
import { isNativeMobileApp } from '../../../Utils/Platform';
const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
if (
@@ -66,8 +60,6 @@ const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
return 'play';
} else if (routeArguments['initial-dialog'] === 'learn') {
return 'learn';
} else if (routeArguments['initial-dialog'] === 'get-started') {
return 'get-started';
}
return null;
@@ -149,7 +141,8 @@ type Props = {|
) => Promise<void>,
onOpenTemplateFromTutorial: (tutorialId: string) => Promise<void>,
onOpenTemplateFromCourseChapter: (
courseChapter: CourseChapter
CourseChapter,
templateId?: string
) => Promise<void>,
// Project save
@@ -217,8 +210,6 @@ export const HomePage = React.memo<Props>(
startTimeoutToUnloadIframe,
loadIframeOrRemoveTimeout,
} = gamesPlatformFrameTools;
const userSurveyStartedRef = React.useRef<boolean>(false);
const userSurveyHiddenRef = React.useRef<boolean>(false);
const { fetchTutorials } = React.useContext(TutorialContext);
const { fetchExamplesAndFilters } = React.useContext(ExampleStoreContext);
const {
@@ -248,8 +239,11 @@ export const HomePage = React.memo<Props>(
onResetEducationForm,
} = useEducationForm({ authenticatedUser });
const {
courses,
selectedCourse,
courseChapters,
courseChaptersByCourseId,
onSelectCourse,
fetchCourses,
areChaptersReady,
onCompleteTask,
isTaskCompleted,
@@ -263,17 +257,17 @@ export const HomePage = React.memo<Props>(
] = React.useState<TutorialCategory | null>(null);
const { isMobile } = useResponsiveWindowSize();
const {
values: { showGetStartedSectionByDefault },
} = React.useContext(PreferencesContext);
const tabRequestedAtOpening = React.useRef<HomeTab | null>(
getRequestedTab(routeArguments)
);
const programOpeningCount = getProgramOpeningCount();
const initialTab = tabRequestedAtOpening.current
? tabRequestedAtOpening.current
: showGetStartedSectionByDefault
? 'get-started'
: 'create';
: isNativeMobileApp()
? 'play'
: programOpeningCount > 1
? 'create'
: 'learn';
const [activeTab, setActiveTab] = React.useState<HomeTab>(initialTab);
@@ -287,9 +281,6 @@ export const HomePage = React.memo<Props>(
: games.find(game => game.id === openedGameId),
[games, openedGameId]
);
const { subscriptionPlansWithPricingSystems } = useSubscriptionPlans({
includeLegacy: false,
});
// Open the store and a pack or game template if asked to do so, either at
// app opening, either when the route changes (when clicking on an announcement
@@ -332,18 +323,16 @@ export const HomePage = React.memo<Props>(
// Do not process requested tab before courses are ready.
return;
}
if (courseId && selectedCourse && selectedCourse.id === courseId) {
setLearnCategory('course');
removeRouteArguments(['course-id']);
}
onSelectCourse(courseId);
setLearnCategory('course');
removeRouteArguments(['course-id']);
}
removeRouteArguments(['initial-dialog']);
},
[
routeArguments,
selectedCourse,
onSelectCourse,
removeRouteArguments,
setInitialPackUserFriendlySlug,
setInitialGameTemplateUserFriendlySlug,
@@ -352,15 +341,6 @@ export const HomePage = React.memo<Props>(
]
);
React.useEffect(
() => {
if (initialTab === 'get-started') {
incrementGetStartedSectionViewCount();
}
},
[initialTab]
);
// Load everything when the user opens the home page, to avoid future loading times.
React.useEffect(
() => {
@@ -371,16 +351,14 @@ export const HomePage = React.memo<Props>(
[fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates]
);
// Only fetch games if the user decides to open the games dashboard tab
// or the build tab to enable the context menu on project list items that
// redirects to the games dashboard.
// Only fetch courses if the user decides to open the Learn section.
React.useEffect(
() => {
if (activeTab === 'create' && !games) {
fetchGames();
if (activeTab === 'learn' && !courses) {
fetchCourses();
}
},
[fetchGames, activeTab, games]
[fetchCourses, activeTab, courses]
);
// Fetch user cloud projects when home page becomes active
@@ -478,28 +456,6 @@ export const HomePage = React.memo<Props>(
onSceneObjectEdited,
}));
const onUserSurveyStarted = React.useCallback(() => {
if (userSurveyStartedRef.current) return;
sendUserSurveyStarted();
userSurveyStartedRef.current = true;
}, []);
const onUserSurveyHidden = React.useCallback(() => {
if (userSurveyHiddenRef.current) return;
sendUserSurveyHidden();
userSurveyHiddenRef.current = true;
}, []);
React.useEffect(
() => {
if (!authenticated) {
userSurveyStartedRef.current = false;
userSurveyHiddenRef.current = false;
}
},
// Reset flag that prevents multiple send of the same event on user change.
[authenticated]
);
// As the homepage is never unmounted, we need to ensure the games platform
// iframe is unloaded & loaded from here,
// allowing to handle when the user navigates to another tab.
@@ -529,6 +485,10 @@ export const HomePage = React.memo<Props>(
]
);
const premiumCourse = courses
? courses.find(course => course.id === 'premium-course')
: null;
return (
<I18n>
{({ i18n }) => (
@@ -565,19 +525,6 @@ export const HomePage = React.memo<Props>(
canSaveProject={canSave}
/>
)}
{activeTab === 'get-started' && (
<GetStartedSection
selectInAppTutorial={selectInAppTutorial}
onUserSurveyStarted={onUserSurveyStarted}
onUserSurveyHidden={onUserSurveyHidden}
subscriptionPlansWithPricingSystems={
subscriptionPlansWithPricingSystems
}
onOpenProfile={onOpenProfile}
onCreateProjectFromExample={onCreateProjectFromExample}
askToCloseProject={askToCloseProject}
/>
)}
{activeTab === 'learn' && (
<LearnSection
onTabChange={setActiveTab}
@@ -588,8 +535,20 @@ export const HomePage = React.memo<Props>(
}
selectedCategory={learnCategory}
onSelectCategory={setLearnCategory}
onSelectCourse={onSelectCourse}
courses={courses}
previewedCourse={premiumCourse}
previewedCourseChapters={
premiumCourse
? courseChaptersByCourseId[premiumCourse.id]
: null
}
course={selectedCourse}
courseChapters={courseChapters}
courseChapters={
selectedCourse
? courseChaptersByCourseId[selectedCourse.id]
: null
}
onCompleteCourseTask={onCompleteTask}
isCourseTaskCompleted={isTaskCompleted}
getCourseChapterCompletion={getChapterCompletion}

View File

@@ -10,6 +10,7 @@ import {
type MainMenuEvent,
} from './MainMenu';
import PreferencesContext from './Preferences/PreferencesContext';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
const electron = optionalRequire('electron');
const remote = optionalRequire('@electron/remote');
const app = remote ? remote.app : null;
@@ -102,6 +103,12 @@ const ElectronMainMenu = ({
const {
values: { useShortcutToClosePreviewWindow },
} = React.useContext(PreferencesContext);
const { limits } = React.useContext(AuthenticatedUserContext);
const hideAskAi =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hideAskAi;
useAppEventListener({
event: 'browser-window-focus',
@@ -233,6 +240,7 @@ const ElectronMainMenu = ({
recentProjectFiles,
shortcutMap,
isApplicationTopLevelMenu,
hideAskAi,
})
);
}
@@ -245,6 +253,7 @@ const ElectronMainMenu = ({
recentProjectFiles,
shortcutMap,
isApplicationTopLevelMenu,
hideAskAi,
]
);

View File

@@ -23,6 +23,7 @@ export type BuildMainMenuProps = {|
recentProjectFiles: Array<FileMetadataAndStorageProviderName>,
shortcutMap: ShortcutMap,
isApplicationTopLevelMenu: boolean,
hideAskAi: boolean,
|};
export type MainMenuCallbacks = {|
@@ -111,6 +112,7 @@ export const buildMainMenuDeclarativeTemplate = ({
project,
canSaveProjectAs,
isApplicationTopLevelMenu,
hideAskAi,
}: BuildMainMenuProps): Array<MenuDeclarativeItemTemplate> => {
const fileTemplate: MenuDeclarativeItemTemplate = {
label: i18n._(t`File`),
@@ -257,10 +259,14 @@ export const buildMainMenuDeclarativeTemplate = ({
label: i18n._(t`Help`),
role: 'help',
submenu: [
// {
// label: i18n._(t`Ask AI (GDevelop chatbot)`),
// onClickSendEvent: 'main-menu-open-ask-ai',
// },
...(hideAskAi
? []
: [
{
label: i18n._(t`Ask AI (GDevelop chatbot)`),
onClickSendEvent: 'main-menu-open-ask-ai',
},
]),
{
label: i18n._(t`GDevelop website`),
onClickOpenLink: 'http://gdevelop.io',
@@ -323,7 +329,7 @@ export const buildMainMenuDeclarativeTemplate = ({
},
{ type: 'separator' },
{
label: i18n._(t`Help to Translate GDevelop`),
label: i18n._(t`Help translate GDevelop`),
onClickOpenLink: 'https://crowdin.com/project/gdevelop',
},
{

View File

@@ -46,23 +46,28 @@ const localesToDisplay = LocalesMetadata.filter(displayLocaleMetadata).map(
}
);
const goodProgressLocales = localesToDisplay.filter(
localeMetadata => localeMetadata.translationRatio > 0.5
localeMetadata => localeMetadata.translationRatio > 0.3
);
const startedLocales = localesToDisplay.filter(
localeMetadata => localeMetadata.translationRatio < 0.5
const incompleteLocales = localesToDisplay.filter(
localeMetadata => localeMetadata.translationRatio < 0.3
);
const renderLanguageSelectOption = localeMetadata => {
const translationRatio = localeMetadata.translationRatio || 0;
const percent = (100 * localeMetadata.translationRatio).toFixed(0);
const isIncomplete = translationRatio < 0.3;
const isStarted = translationRatio > 0;
const label = !isIncomplete
? `${localeMetadata.languageNativeName} (${localeMetadata.languageName})`
: `${localeMetadata.languageNativeName} (${
localeMetadata.languageName
} - ${percent}% translated)`;
return (
<SelectOption
value={localeMetadata.languageCode}
label={`${localeMetadata.languageNativeName} (${
localeMetadata.languageName
})${isStarted ? ` - ~${percent}%` : ''}`}
label={label}
disabled={!isStarted}
key={localeMetadata.languageCode}
/>
@@ -92,7 +97,7 @@ const LanguageSelector = ({ onLanguageChanged }: Props) => {
{goodProgressLocales.map(localeMetadata =>
renderLanguageSelectOption(localeMetadata)
)}
{startedLocales.map(localeMetadata =>
{incompleteLocales.map(localeMetadata =>
renderLanguageSelectOption(localeMetadata)
)}
</CompactSelectField>
@@ -107,7 +112,7 @@ const LanguageSelector = ({ onLanguageChanged }: Props) => {
Window.openExternalURL('https://crowdin.com/project/gdevelop')
}
>
help to translate GDevelop in your language
help translate GDevelop into your language
</Link>
.
</Trans>

View File

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

View File

@@ -149,9 +149,6 @@ export default class PreferencesProvider extends React.Component<Props, State> {
this
),
setShowCommunityExtensions: this._setShowCommunityExtensions.bind(this),
setShowGetStartedSectionByDefault: this._setShowGetStartedSection.bind(
this
),
setShowInAppTutorialDeveloperMode: this._setShowInAppTutorialDeveloperMode.bind(
this
),
@@ -353,18 +350,6 @@ export default class PreferencesProvider extends React.Component<Props, State> {
);
}
_setShowGetStartedSection(showGetStartedSectionByDefault: boolean) {
this.setState(
state => ({
values: {
...state.values,
showGetStartedSectionByDefault,
},
}),
() => this._persistValuesToLocalStorage(this.state)
);
}
_setThemeName(themeName: string) {
this.setState(
state => ({

View File

@@ -41,6 +41,7 @@ import { CreditsPackageStoreStateProvider } from '../AssetStore/CreditsPackages/
import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/ProductLicenseStoreContext';
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
import { CourseChapterStoreStateProvider } from '../Course/CourseChapterStoreContext';
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
type Props = {|
authentication: Authentication,
@@ -119,9 +120,13 @@ const Providers = ({
<TutorialStateProvider>
<AnnouncementsFeedStateProvider>
<PrivateAssetsAuthorizationProvider>
{children({
i18n,
})}
<Resource3DPreviewProvider>
{children(
{
i18n,
}
)}
</Resource3DPreviewProvider>
</PrivateAssetsAuthorizationProvider>
</AnnouncementsFeedStateProvider>
</TutorialStateProvider>

View File

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

View File

@@ -13,6 +13,12 @@ import { type EditorTab } from './EditorTabs/EditorTabsHandler';
import { getTabId } from './EditorTabs/DraggableEditorTabs';
import { useScreenType } from '../UI/Responsive/ScreenTypeMeasurer';
import TabsTitlebarTooltip from './TabsTitlebarTooltip';
import RobotIcon from '../ProjectCreation/RobotIcon';
import PreferencesContext from './Preferences/PreferencesContext';
import TextButton from '../UI/TextButton';
import { useInterval } from '../Utils/UseInterval';
import { useIsMounted } from '../Utils/UseIsMounted';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
const WINDOW_DRAGGABLE_PART_CLASS_NAME = 'title-bar-draggable-part';
const WINDOW_NON_DRAGGABLE_PART_CLASS_NAME = 'title-bar-non-draggable-part';
@@ -34,7 +40,7 @@ const styles = {
},
askAiContainer: {
marginBottom: 4,
marginRight: 0,
marginRight: 1,
marginLeft: 2,
},
};
@@ -50,6 +56,46 @@ type TabsTitlebarProps = {|
onOpenAskAi: () => void,
|};
const useIsAskAiIconAnimated = (shouldDisplayAskAi: boolean) => {
const isMounted = useIsMounted();
const [isAskAiIconAnimated, setIsAskAiIconAnimated] = React.useState(true);
const animate = React.useCallback(
(animationDuration: number) => {
if (isMounted.current) {
setIsAskAiIconAnimated(true);
setTimeout(() => {
if (!isMounted.current) return;
setIsAskAiIconAnimated(false);
}, animationDuration);
}
},
[isMounted]
);
React.useEffect(
() => {
// Animate the icon for a long time at the beginning.
animate(9000);
},
[animate]
);
useInterval(
() => {
setIsAskAiIconAnimated(true);
setTimeout(() => {
setIsAskAiIconAnimated(false);
}, 8000);
},
// Animate the icon every 20 minutes.
shouldDisplayAskAi ? 20 * 60 * 1000 : null
);
return isAskAiIconAnimated;
};
/**
* The titlebar containing a menu, the tabs and giving space for window controls.
*/
@@ -63,6 +109,8 @@ export default function TabsTitlebar({
const isTouchscreen = useScreenType() === 'touch';
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const backgroundColor = gdevelopTheme.titlebar.backgroundColor;
const preferences = React.useContext(PreferencesContext);
const { limits } = React.useContext(AuthenticatedUserContext);
const [tooltipData, setTooltipData] = React.useState<?{|
element: HTMLElement,
editorTab: EditorTab,
@@ -135,6 +183,17 @@ export default function TabsTitlebar({
[]
);
const hideAskAi =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hideAskAi;
const shouldDisplayAskAi =
preferences.values.showAiAskButtonInTitleBar &&
!hasAskAiOpened &&
!hideAskAi;
const isAskAiIconAnimated = useIsAskAiIconAnimated(shouldDisplayAskAi);
return (
<div
style={{
@@ -160,17 +219,18 @@ export default function TabsTitlebar({
<MenuIcon />
</IconButton>
{renderTabs(onEditorTabHovered, onEditorTabClosing)}
{/* {!preferences.values.showAiAskButtonInTitleBar ||
hasAskAiOpened ? null : (
<div style={styles.askAiContainer}>
<RaisedButton
icon={<RobotIcon size={16} />}
color="primary"
{shouldDisplayAskAi ? (
<div
style={styles.askAiContainer}
className={WINDOW_NON_DRAGGABLE_PART_CLASS_NAME}
>
<TextButton
icon={<RobotIcon size={16} rotating={isAskAiIconAnimated} />}
label={'Ask AI'}
onClick={onOpenAskAi}
/>
</div>
)} */}
) : null}
<TitleBarRightSafeMargins />
{tooltipData && (
<TabsTitlebarTooltip

View File

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

View File

@@ -3167,6 +3167,7 @@ const MainFrame = (props: Props) => {
useOpenInitialDialog({
openInAppTutorialDialog: selectInAppTutorial,
openProfileDialog: onOpenProfileDialog,
openAskAi,
});
const onChangeProjectName = async (newName: string): Promise<void> => {
@@ -3289,16 +3290,20 @@ const MainFrame = (props: Props) => {
);
const openTemplateFromCourseChapter = React.useCallback(
async (courseChapter: CourseChapter) => {
async (courseChapter: CourseChapter, templateId?: string) => {
const projectIsClosed = await askToCloseProject();
if (!projectIsClosed) {
return;
}
try {
await createProjectFromCourseChapter(courseChapter, {
storageProvider: emptyStorageProvider,
saveAsLocation: null,
// Remaining will be set by the template.
await createProjectFromCourseChapter({
courseChapter,
templateId,
newProjectSetup: {
storageProvider: emptyStorageProvider,
saveAsLocation: null,
// Remaining will be set by the template.
},
});
} catch (error) {
showErrorBox({
@@ -3615,6 +3620,10 @@ const MainFrame = (props: Props) => {
]
);
const hideAskAi =
!!authenticatedUser.limits &&
!!authenticatedUser.limits.capabilities.classrooms &&
authenticatedUser.limits.capabilities.classrooms.hideAskAi;
const showLoader = isProjectOpening || isLoadingProject || previewLoading;
const shortcutMap = useShortcutMap();
const buildMainMenuProps = {
@@ -3624,6 +3633,7 @@ const MainFrame = (props: Props) => {
recentProjectFiles: preferences.getRecentProjectFiles({ limit: 20 }),
shortcutMap,
isApplicationTopLevelMenu: false,
hideAskAi,
};
const mainMenuCallbacks = {
onChooseProject: () => openOpenFromStorageProviderDialog(),

View File

@@ -30,11 +30,8 @@ import useAlertDialog from '../../UI/Alert/useAlertDialog';
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils';
import * as THREE from 'three';
import {
PropertyCheckbox,
PropertyField,
PropertyResourceSelector,
} from './PropertyFields';
import { PropertyCheckbox, PropertyField } from './PropertyFields';
import ResourceSelectorWithThumbnail from '../../ResourcesList/ResourceSelectorWithThumbnail';
const gd: libGDevelop = global.gd;
@@ -485,14 +482,18 @@ const Model3DEditor = ({
<ScrollView ref={scrollView}>
<ColumnStackLayout noMargin>
{renderObjectNameField && renderObjectNameField()}
<PropertyResourceSelector
objectConfiguration={objectConfiguration}
propertyName="modelResourceName"
<ResourceSelectorWithThumbnail
project={project}
resourceKind="model3D"
floatingLabelText={properties.get('modelResourceName').getLabel()}
resourceManagementProps={resourceManagementProps}
onChange={resourceName => {
loadGltf(resourceName);
resourceName={properties.get('modelResourceName').getValue()}
onChange={newValue => {
onChangeProperty('modelResourceName', newValue);
loadGltf(newValue);
forceUpdate();
}}
id={`model3d-object-modelResourceName`}
/>
<SelectField
value={properties.get('materialType').getValue()}

View File

@@ -40,7 +40,10 @@ import {
} from '../../../ResourcesList/ResourceExternalEditor';
import { showErrorBox } from '../../../UI/Messages/MessageBox';
import { type UnsavedChanges } from '../../../MainFrame/UnsavedChangesContext';
import { type ResourceManagementProps } from '../../../ResourcesList/ResourceSource';
import {
type ResourceManagementProps,
type ResourceSource,
} from '../../../ResourcesList/ResourceSource';
import { type ScrollViewInterface } from '../../../UI/ScrollView';
import ThreeDotsMenu from '../../../UI/CustomSvgIcons/ThreeDotsMenu';
import ElementWithMenu from '../../../UI/Menu/ElementWithMenu';
@@ -439,36 +442,44 @@ const AnimationList = React.forwardRef<
);
const importImages = React.useCallback(
async () => {
async (resourceSource: ResourceSource) => {
const resources = await resourceManagementProps.onChooseResource({
initialSourceName: resourceSources[0].name,
initialSourceName: resourceSource.name,
multiSelection: true,
resourceKind: 'image',
});
if (resources.length === 0) {
return;
}
resources.forEach(resource => {
applyResourceDefaults(project, resource);
project.getResourcesManager().addResource(resource);
});
if (resourceSource.shouldCreateResource) {
resources.forEach(resource => {
applyResourceDefaults(project, resource);
project.getResourcesManager().addResource(resource);
});
addAnimations(groupResourcesByAnimations(resources));
const resourcesByAnimation = resourceSource.shouldGuessAnimationsFromName
? groupResourcesByAnimations(resources)
: new Map<string, Array<gdResource>>();
addAnimations(resourcesByAnimation);
// Important, we are responsible for deleting the resources that were given to us.
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
resources.forEach(resource => resource.delete());
// Important, we are responsible for deleting the resources that were given to us.
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
resources.forEach(resource => resource.delete());
await resourceManagementProps.onFetchNewlyAddedResources();
} else {
const resourcesByAnimation = new Map<string, Array<gdResource>>();
resourcesByAnimation.set('default', resources);
addAnimations(resourcesByAnimation);
}
forceUpdate();
await resourceManagementProps.onFetchNewlyAddedResources();
adaptCollisionMaskIfNeeded();
if (onObjectUpdated) onObjectUpdated();
},
[
resourceManagementProps,
resourceSources,
addAnimations,
forceUpdate,
adaptCollisionMaskIfNeeded,
@@ -662,8 +673,17 @@ const AnimationList = React.forwardRef<
helpPagePath="/objects/sprite"
tutorialId="intermediate-changing-animations"
onAction={() => {
importImages();
importImages(resourceSources[0]);
}}
actionBuildSplitMenuTemplate={
resourceSources.length < 2
? undefined
: i18n =>
resourceSources.map(resourceSource => ({
label: i18n._(resourceSource.displayName),
click: () => importImages(resourceSource),
}))
}
onSecondaryAction={() => {
createAnimationWith(i18n, imageResourceExternalEditors[0]);
}}

View File

@@ -393,12 +393,18 @@ const SpritesList = ({
multiSelection: true,
resourceKind: 'image',
});
resources.forEach(resource => {
applyResourceDefaults(project, resource);
project.getResourcesManager().addResource(resource);
});
if (resourceSource.shouldCreateResource) {
resources.forEach(resource => {
applyResourceDefaults(project, resource);
project.getResourcesManager().addResource(resource);
});
}
if (directionSpritesCountBeforeAdding === 0 && resources.length > 1) {
if (
directionSpritesCountBeforeAdding === 0 &&
resources.length > 1 &&
resourceSource.shouldGuessAnimationsFromName
) {
const resourcesByAnimation = groupResourcesByAnimations(resources);
if (resourcesByAnimation.size > 1) {
addAnimations(resourcesByAnimation);
@@ -416,9 +422,11 @@ const SpritesList = ({
}
}
// Important, we are responsible for deleting the resources that were given to us.
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
resources.forEach(resource => resource.delete());
if (resourceSource.shouldCreateResource) {
// Important, we are responsible for deleting the resources that were given to us.
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
resources.forEach(resource => resource.delete());
}
forceUpdate();

View File

@@ -326,9 +326,9 @@ const ObjectGroupsList = React.forwardRef<Props, ObjectGroupsListInterface>(
const answer = await showConfirmation({
title: t`Set as global group`,
message: t`Global elements help to manage objects across multiple scenes and it is recommended for the most used objects.
This action cannot be undone.
Do you want to set as global group?`,
message: t`Global elements help manage objects across multiple scenes and are recommended for frequently used objects. This action cannot be undone.
Do you want to set this as global group?`,
confirmButtonLabel: t`Set as global`,
});
if (!answer) return;

View File

@@ -819,9 +819,9 @@ const ObjectsList = React.forwardRef<Props, ObjectsListInterface>(
const answer = Window.showConfirmDialog(
i18n._(
t`Global elements help to manage objects across multiple scenes and it is recommended for the most used objects. This action cannot be undone.
t`Global elements help manage objects across multiple scenes and are recommended for frequently used objects. This action cannot be undone.
Do you want to set as global object?`
Do you want to set this as global object?`
)
);
if (!answer) return;

View File

@@ -155,12 +155,14 @@ const applyThreeTextureSettings = (
}
};
// If modifying this function, make sure to update Resource3DPreview.worker.js copy.
const removeMetalness = (material: THREE.Material): void => {
if (material.metalness) {
material.metalness = 0;
}
};
// If modifying this function, make sure to update Resource3DPreview.worker.js copy.
const removeMetalnessFromMesh = (node: THREE.Object3D): void => {
const mesh = (node: THREE.Mesh);
if (!mesh.material) {

View File

@@ -19,8 +19,6 @@ import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
type Props = {|
badges: Array<BadgeType>,
achievements: Array<Achievement>,
displayUnclaimedAchievements: boolean,
displayNotifications: boolean,
|};
const styles = {
@@ -33,12 +31,7 @@ const styles = {
unlockedAchievement: {},
};
const AchievementList = ({
badges,
achievements,
displayUnclaimedAchievements,
displayNotifications,
}: Props) => {
const AchievementList = ({ badges, achievements }: Props) => {
const [
achievementsWithBadgeData,
setAchievementsWithBadgeData,
@@ -55,13 +48,11 @@ const AchievementList = ({
(acc, achievement) => {
const badge = badgeByAchievementId[achievement.id];
const hasBadge = !!badge;
if (hasBadge || (!hasBadge && displayUnclaimedAchievements)) {
acc.push({
...achievement,
seen: hasBadge ? badge.seen : undefined,
unlockedAt: hasBadge ? parseISO(badge.unlockedAt) : null,
});
}
acc.push({
...achievement,
seen: hasBadge ? badge.seen : undefined,
unlockedAt: hasBadge ? parseISO(badge.unlockedAt) : null,
});
return acc;
},
@@ -72,7 +63,7 @@ const AchievementList = ({
setAchievementsWithBadgeData(achievementsWithBadgeData);
},
[badges, achievements, displayUnclaimedAchievements]
[badges, achievements]
);
return (
@@ -88,10 +79,8 @@ const AchievementList = ({
<Column justifyContent="center" alignItems="flex-start">
<DotBadge
invisible={
!(
displayNotifications &&
achievementWithBadgeData.seen === false
)
achievementWithBadgeData.seen === true ||
achievementWithBadgeData.seen === undefined
}
>
<Text
@@ -109,22 +98,20 @@ const AchievementList = ({
)}
</Text>
</DotBadge>
{displayUnclaimedAchievements && (
<Text
noMargin
style={
achievementWithBadgeData.unlockedAt
? styles.unlockedAchievement
: styles.lockedAchievement
}
size="body2"
>
{selectMessageByLocale(
i18n,
achievementWithBadgeData.descriptionByLocale
)}
</Text>
)}
<Text
noMargin
style={
achievementWithBadgeData.unlockedAt
? styles.unlockedAchievement
: styles.lockedAchievement
}
size="body2"
>
{selectMessageByLocale(
i18n,
achievementWithBadgeData.descriptionByLocale
)}
</Text>
</Column>
<Column>
{achievementWithBadgeData.unlockedAt ? (

View File

@@ -15,13 +15,6 @@ import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMea
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import { Column } from '../../UI/Grid';
type Props = {|
achievements: ?Array<Achievement>,
badges: ?Array<Badge>,
displayUnclaimedAchievements: boolean,
displayNotifications: boolean,
|};
const styles = {
summary: {
textAlign: 'center',
@@ -38,12 +31,12 @@ const styles = {
},
};
const UserAchievements = ({
achievements,
badges,
displayUnclaimedAchievements,
displayNotifications,
}: Props) => {
type Props = {|
achievements: ?Array<Achievement>,
badges: ?Array<Badge>,
|};
const UserAchievements = ({ achievements, badges }: Props) => {
const { isMobile } = useResponsiveWindowSize();
return (
@@ -81,8 +74,6 @@ const UserAchievements = ({
<AchievementList
badges={badges}
achievements={achievements}
displayUnclaimedAchievements={displayUnclaimedAchievements}
displayNotifications={displayNotifications}
/>
)}
</div>

View File

@@ -227,8 +227,6 @@ const ProfileDialog = ({ onClose }: Props) => {
<UserAchievements
achievements={authenticatedUser.achievements}
badges={authenticatedUser.badges}
displayUnclaimedAchievements
displayNotifications
/>
</div>
)}

View File

@@ -27,7 +27,8 @@ const useClasses = (rotating, size) =>
transform: 'translate(-50%, -50%)',
borderRadius: '100%',
zIndex: 0,
animation: rotating ? 'spin-background 3s linear infinite' : 'none',
animation: 'spin-background 3s linear infinite',
animationPlayState: rotating ? 'running' : 'paused',
},
'&::after': {
content: "''",

View File

@@ -85,6 +85,23 @@ const ResourcePropertiesEditor = React.forwardRef<
[resourceManagementProps, resources, onResourcePathUpdated, forceUpdate]
);
const resourceSources = React.useMemo(
() => {
const storageProvider = resourceManagementProps.getStorageProvider();
return resources.length
? resourceManagementProps.resourceSources
.filter(source => source.kind === resources[0].getKind())
.filter(
({ onlyForStorageProvider }) =>
!onlyForStorageProvider ||
onlyForStorageProvider === storageProvider.internalName
)
.filter(source => !source.hideInResourceEditor)
: [];
},
[resourceManagementProps, resources]
);
const schema: Schema = React.useMemo(
() => [
{
@@ -102,35 +119,20 @@ const ResourcePropertiesEditor = React.forwardRef<
setValue: (resource: gdResource, newValue: string) =>
resource.setFile(newValue),
onEditButtonClick: () => {
const storageProvider = resourceManagementProps.getStorageProvider();
const resourceSources = resourceManagementProps.resourceSources
.filter(source => source.kind === resources[0].getKind())
.filter(
({ onlyForStorageProvider }) =>
!onlyForStorageProvider ||
onlyForStorageProvider === storageProvider.internalName
);
const firstResourceSource = resourceSources[0];
if (firstResourceSource) chooseResourcePath(firstResourceSource);
},
onEditButtonBuildMenuTemplate: (i18n: I18nType) => {
const storageProvider = resourceManagementProps.getStorageProvider();
return resourceManagementProps.resourceSources
.filter(source => source.kind === resources[0].getKind())
.filter(
({ onlyForStorageProvider }) =>
!onlyForStorageProvider ||
onlyForStorageProvider === storageProvider.internalName
)
.map(source => ({
label: i18n._(source.displayName),
click: () => chooseResourcePath(source),
}));
},
onEditButtonBuildMenuTemplate:
resourceSources.length > 1
? (i18n: I18nType) =>
resourceSources.map(source => ({
label: i18n._(source.displayName),
click: () => chooseResourcePath(source),
}))
: undefined,
},
],
[resourceManagementProps, resources, chooseResourcePath]
[resourceSources, chooseResourcePath]
);
const renderResourcesProperties = React.useCallback(

View File

@@ -2,7 +2,7 @@
import { t } from '@lingui/macro';
import * as React from 'react';
import ResourcesList from '../ResourcesList';
import ResourcesList, { type ResourcesListInterface } from '../ResourcesList';
import ResourcePropertiesEditor, {
type ResourcePropertiesEditorInterface,
} from './ResourcePropertiesEditor';
@@ -73,7 +73,7 @@ export default class ResourcesEditor extends React.Component<Props, State> {
resourceExternallyChangedCallbackId: ?string;
editorMosaic: ?EditorMosaicInterface = null;
_propertiesEditor: ?ResourcePropertiesEditorInterface = null;
_resourcesList: ?ResourcesList = null;
_resourcesList: ?ResourcesListInterface = null;
resourcesLoader = ResourcesLoader;
state = {
selectedResource: null,
@@ -91,7 +91,7 @@ export default class ResourcesEditor extends React.Component<Props, State> {
}
refreshResourcesList() {
if (this._resourcesList) this._resourcesList.forceUpdate();
if (this._resourcesList) this._resourcesList.forceUpdateList();
}
updateToolbar = () => {

View File

@@ -3,7 +3,7 @@ import { t, Trans } from '@lingui/macro';
import * as React from 'react';
import {
type ResourceSourceComponentProps,
type ResourceStorePrimaryActionProps,
type ResourceSourceComponentPrimaryActionProps,
type ResourceSource,
type ResourceStoreChooserProps,
allResourceKindsAndMetadata,
@@ -24,6 +24,7 @@ import {
isPublicAssetResourceUrl,
} from '../Utils/GDevelopServices/Asset';
import { DialogPrimaryButton } from '../UI/Dialog';
import ProjectResourcesChooser from './ProjectResources/ProjectResourcesChooser';
const ResourceStoreChooser = ({
options,
@@ -179,6 +180,8 @@ const browserResourceSources: Array<ResourceSource> = [
name: `upload-${kind}`,
displayName: t`File(s) from your device`,
displayTab: 'import',
shouldCreateResource: true,
shouldGuessAnimationsFromName: true,
kind,
renderComponent: (props: ResourceSourceComponentProps) => (
<FileToCloudProjectResourceUploader
@@ -202,6 +205,8 @@ const browserResourceSources: Array<ResourceSource> = [
name: `resource-store-${kind}`,
displayName: t`Choose from asset store`,
displayTab: 'standalone',
shouldCreateResource: true,
shouldGuessAnimationsFromName: false,
kind,
renderComponent: (props: ResourceSourceComponentProps) => (
<ResourceStoreChooser
@@ -214,7 +219,7 @@ const browserResourceSources: Array<ResourceSource> = [
renderPrimaryAction: ({
resource,
onChooseResources,
}: ResourceStorePrimaryActionProps) => (
}: ResourceSourceComponentPrimaryActionProps) => (
<DialogPrimaryButton
primary
key="add-resource"
@@ -248,10 +253,53 @@ const browserResourceSources: Array<ResourceSource> = [
};
})
.filter(Boolean),
...allResourceKindsAndMetadata.map(({ kind, createNewResource }) => ({
name: `project-resources-${kind}`,
displayName: t`Project resources`,
displayTab: 'standalone',
shouldCreateResource: false,
shouldGuessAnimationsFromName: false,
hideInResourceEditor: true,
kind,
renderComponent: (props: ResourceSourceComponentProps) => (
<ProjectResourcesChooser
project={props.project}
onResourcesSelected={props.onResourcesSelected}
resourceKind={kind}
key={`project-resources-${kind}`}
multiSelection={props.options.multiSelection}
/>
),
renderPrimaryAction: ({
selectedResources,
onChooseResources,
}: ResourceSourceComponentPrimaryActionProps) => (
<DialogPrimaryButton
primary
key="select-resources"
label={
!selectedResources ||
!selectedResources.length ||
selectedResources.length === 1 ? (
<Trans>Select resource</Trans>
) : (
<Trans>Select {selectedResources.length} resources</Trans>
)
}
disabled={!selectedResources || !selectedResources.length}
onClick={() => {
if (!selectedResources || !selectedResources.length) return;
onChooseResources(selectedResources);
}}
/>
),
})),
...allResourceKindsAndMetadata.map(({ kind, createNewResource }) => ({
name: `url-chooser-${kind}`,
displayName: t`Use a public URL`,
displayTab: 'import-advanced',
shouldCreateResource: true,
shouldGuessAnimationsFromName: false,
kind,
renderComponent: (props: ResourceSourceComponentProps) => (
<UrlChooser

View File

@@ -84,20 +84,24 @@ export const CompactResourceSelectorWithThumbnail = ({
if (!resources.length) return;
const resource = resources[0];
applyResourceDefaults(project, resource);
// addResource will check if a resource with the same name exists, and if it is
// the case, no new resource will be added.
project.getResourcesManager().addResource(resource);
const resourceName: string = resource.getName();
// Important, we are responsible for deleting the resources that were given to us.
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
resources.forEach(resource => resource.delete());
if (source.shouldCreateResource) {
applyResourceDefaults(project, resource);
// addResource will check if a resource with the same name exists, and if it is
// the case, no new resource will be added.
project.getResourcesManager().addResource(resource);
// Important, we are responsible for deleting the resources that were given to us.
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
resources.forEach(resource => resource.delete());
await resourceManagementProps.onFetchNewlyAddedResources();
triggerResourcesHaveChanged();
}
await resourceManagementProps.onFetchNewlyAddedResources();
triggerResourcesHaveChanged();
onChange(resourceName);
} catch (err) {
// Should never happen, errors should be shown in the interface.
@@ -267,9 +271,13 @@ export const CompactResourceSelectorWithThumbnail = ({
label: i18n._(resourceSource.displayName),
click: () => addFrom(resourceSource),
})),
{
type: 'separator',
},
...(externalEditors.length
? [
{
type: 'separator',
},
]
: []),
...externalEditors.map(externalEditor => ({
label: resourceName
? i18n._(externalEditor.editDisplayName)

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import {
type ChooseResourceProps,
type ResourceSourceComponentProps,
type ResourceStorePrimaryActionProps,
type ResourceSourceComponentPrimaryActionProps,
type ResourceSource,
type ResourceStoreChooserProps,
allResourceKindsAndMetadata,
@@ -26,6 +26,7 @@ import { Line } from '../UI/Grid';
import RaisedButton from '../UI/RaisedButton';
import { FileToCloudProjectResourceUploader } from './FileToCloudProjectResourceUploader';
import { DialogPrimaryButton } from '../UI/Dialog';
import ProjectResourcesChooser from './ProjectResources/ProjectResourcesChooser';
const remote = optionalRequire('@electron/remote');
const dialog = remote ? remote.dialog : null;
@@ -208,9 +209,11 @@ const localResourceSources: Array<ResourceSource> = [
return {
name: 'local-file-opener-' + kind,
displayName: t`Choose a file`,
displayName: t`File(s) from your device`,
displayTab: 'import',
onlyForStorageProvider: 'LocalFile',
shouldCreateResource: true,
shouldGuessAnimationsFromName: true,
kind,
selectResourcesHeadless: selectLocalFileResources,
renderComponent: (props: ResourceSourceComponentProps) => (
@@ -248,6 +251,8 @@ const localResourceSources: Array<ResourceSource> = [
...allResourceKindsAndMetadata.map(({ kind, createNewResource }) => ({
name: `upload-${kind}`,
displayName: t`File(s) from your device`,
shouldCreateResource: true,
shouldGuessAnimationsFromName: true,
displayTab: 'import',
onlyForStorageProvider: 'Cloud',
kind,
@@ -274,6 +279,8 @@ const localResourceSources: Array<ResourceSource> = [
displayName: t`Choose from asset store`,
displayTab: 'standalone',
kind,
shouldCreateResource: true,
shouldGuessAnimationsFromName: false,
renderComponent: (props: ResourceSourceComponentProps) => (
<ResourceStoreChooser
selectedResourceIndex={props.selectedResourceIndex}
@@ -285,7 +292,7 @@ const localResourceSources: Array<ResourceSource> = [
renderPrimaryAction: ({
resource,
onChooseResources,
}: ResourceStorePrimaryActionProps) => (
}: ResourceSourceComponentPrimaryActionProps) => (
<DialogPrimaryButton
primary
key="add-resource"
@@ -312,6 +319,47 @@ const localResourceSources: Array<ResourceSource> = [
};
})
.filter(Boolean),
...allResourceKindsAndMetadata.map(({ kind, createNewResource }) => ({
name: `project-resources-${kind}`,
displayName: t`Project resources`,
displayTab: 'standalone',
shouldCreateResource: false,
shouldGuessAnimationsFromName: false,
hideInResourceEditor: true,
kind,
renderComponent: (props: ResourceSourceComponentProps) => (
<ProjectResourcesChooser
project={props.project}
onResourcesSelected={props.onResourcesSelected}
resourceKind={kind}
key={`project-resources-${kind}`}
multiSelection={props.options.multiSelection}
/>
),
renderPrimaryAction: ({
selectedResources,
onChooseResources,
}: ResourceSourceComponentPrimaryActionProps) => (
<DialogPrimaryButton
primary
key="select-resources"
label={
!selectedResources ||
!selectedResources.length ||
selectedResources.length === 1 ? (
<Trans>Select resource</Trans>
) : (
<Trans>Select {selectedResources.length} resources</Trans>
)
}
disabled={!selectedResources || !selectedResources.length}
onClick={() => {
if (!selectedResources || !selectedResources.length) return;
onChooseResources(selectedResources);
}}
/>
),
})),
];
export default localResourceSources;

View File

@@ -48,6 +48,9 @@ export const NewResourceDialog = ({
selectedResourceIndex,
setSelectedResourceIndex,
] = React.useState<?number>(null);
const [selectedResources, setSelectedResources] = React.useState<
Array<gdResource>
>([]);
const preferences = React.useContext(PreferencesContext);
const possibleResourceSources = resourceSources
.filter(({ kind }) => kind === options.resourceKind)
@@ -146,8 +149,10 @@ export const NewResourceDialog = ({
searchResults && typeof selectedResourceIndex === 'number'
? searchResults[selectedResourceIndex]
: null;
return source.renderPrimaryAction({
resource: selectedResource,
selectedResources,
onChooseResources,
});
}),
@@ -193,6 +198,8 @@ export const NewResourceDialog = ({
fileMetadata,
selectedResourceIndex,
onSelectResource: setSelectedResourceIndex,
selectedResources,
onResourcesSelected: setSelectedResources,
getStorageProvider,
getLastUsedPath: preferences.getLastUsedPath,
setLastUsedPath: preferences.setLastUsedPath,

View File

@@ -0,0 +1,166 @@
// @flow
import * as React from 'react';
import ButtonBase from '@material-ui/core/ButtonBase';
import ResourcesLoader from '../../ResourcesLoader';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import { CorsAwareImage } from '../../UI/CorsAwareImage';
import Text from '../../UI/Text';
import { getDefaultResourceThumbnail } from '..';
import { getPixelatedImageRendering } from '../../Utils/CssHelpers';
import { isProjectImageResourceSmooth } from '../ResourcePreview/ImagePreview';
import Model3DPreview from '../ResourcePreview/Model3DPreview';
const paddingSize = 10;
const styles = {
previewContainer: {
display: 'flex',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
},
previewImage: {
position: 'relative',
objectFit: 'contain',
verticalAlign: 'middle',
pointerEvents: 'none',
},
previewImagePixelated: {
imageRendering: getPixelatedImageRendering(),
},
cardContainer: {
overflow: 'hidden',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
titleContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
color: '#fff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
backgroundColor: 'rgb(0,0,0,0.5)',
},
title: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
icon: { width: 32, height: 32 },
resourceSimpleImage: {
zIndex: 1,
width: 40,
height: 40,
},
};
type ImagePreviewProps = {|
resource: gdResource,
project: gdProject,
|};
const ImagePreview = ({ resource, project }: ImagePreviewProps) => {
const resourceName = resource.getName();
const resourceThumbnail = ResourcesLoader.getResourceFullUrl(
project,
resourceName,
{}
);
const isImageResourceSmooth = isProjectImageResourceSmooth(
project,
resourceName
);
return (
<CorsAwareImage
key={resourceName}
style={{
...styles.previewImage,
maxWidth: 128 - 2 * paddingSize,
maxHeight: 128 - 2 * paddingSize,
...(!isImageResourceSmooth ? styles.previewImagePixelated : undefined),
}}
src={resourceThumbnail}
alt={resourceName}
/>
);
};
type DefaultPreviewProps = {|
resource: gdResource,
|};
const DefaultPreview = ({ resource }: DefaultPreviewProps) => {
const resourceName = resource.getName();
const resourceThumbnailSrc = getDefaultResourceThumbnail(resource);
return (
<CorsAwareImage
title={resourceName}
alt={resourceName}
src={resourceThumbnailSrc}
style={styles.resourceSimpleImage}
/>
);
};
type Props = {|
project: gdProject,
resource: gdResource,
size: number,
onChoose: () => void,
isSelected?: boolean,
|};
export const ProjectResourceCard = ({
project,
resource,
onChoose,
size,
isSelected,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const resourceName = resource.getName();
const renderResourcePreview = () => {
switch (resource.getKind()) {
case 'image':
return <ImagePreview resource={resource} project={project} />;
case 'model3D':
return (
<Model3DPreview
modelUrl={ResourcesLoader.getResourceFullUrl(
project,
resourceName,
{}
)}
fullWidth
/>
);
default:
return <DefaultPreview resource={resource} />;
}
};
return (
<ButtonBase onClick={onChoose} focusRipple>
<div
style={{
...styles.cardContainer,
width: size,
height: size,
outline: isSelected
? `1px solid ${gdevelopTheme.palette.secondary}`
: undefined,
}}
>
{renderResourcePreview()}
<div style={styles.titleContainer}>
<Text noMargin style={styles.title}>
{resourceName}
</Text>
</div>
</div>
</ButtonBase>
);
};

View File

@@ -0,0 +1,143 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import { filterResourcesList } from '../EnumerateResources';
import { ProjectResourceCard } from './ProjectResourceCard';
import { Column, Line } from '../../UI/Grid';
import { BoxSearchResults } from '../../UI/Search/BoxSearchResults';
import { LineStackLayout } from '../../UI/Layout';
import SearchBar from '../../UI/SearchBar';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import { type ResourceKind } from '../ResourceSource';
import EmptyMessage from '../../UI/EmptyMessage';
type ProjectResourcesListProps = {|
project: gdProject,
onSelectResource: gdResource => void,
selectedResources: gdResource[],
searchResults: gdResource[],
|};
const ProjectResourcesList = ({
project,
searchResults,
onSelectResource,
selectedResources,
}: ProjectResourcesListProps) => {
return (
<Line
expand
overflow={
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
}
noMargin
>
<BoxSearchResults
baseSize={128}
spacing={8}
error={null}
onRetry={() => {}}
searchItems={searchResults}
renderSearchItem={(resource, size, index) => (
<ProjectResourceCard
project={project}
size={size}
resource={resource}
onChoose={() => onSelectResource(resource)}
isSelected={selectedResources.includes(resource)}
/>
)}
noResultPlaceholder={
<EmptyMessage>
<Trans>Could not find any resources matching your search.</Trans>
</EmptyMessage>
}
/>
</Line>
);
};
type Props = {|
project: gdProject,
onResourcesSelected?: (gdResource[]) => void,
resourceKind: ResourceKind,
multiSelection: boolean,
|};
const ProjectResourcesChooser = ({
project,
onResourcesSelected,
resourceKind,
multiSelection,
}: Props) => {
const [searchText, setSearchText] = React.useState<string>('');
const searchResults = React.useMemo(
() => {
const resourcesManager = project.getResourcesManager();
const allResourcesList = resourcesManager
.getAllResourceNames()
.toJSArray()
.map(resourceName => resourcesManager.getResource(resourceName))
.filter(resource => resource.getKind() === resourceKind);
return filterResourcesList(allResourcesList, searchText);
},
[project, searchText, resourceKind]
);
const [selectedResources, setSelectedResources] = React.useState<
gdResource[]
>([]);
const onSelectResource = React.useCallback(
(resource: gdResource) => {
setSelectedResources(prevSelectedResources => {
// If already selected, remove it from the list.
if (prevSelectedResources.includes(resource)) {
return prevSelectedResources.filter(r => r !== resource);
}
// Otherwise, add it to the list. (or create a new list if multiSelection is false)
return multiSelection
? [...prevSelectedResources, resource]
: [resource];
});
},
[multiSelection]
);
React.useEffect(
() => {
if (onResourcesSelected) {
onResourcesSelected(selectedResources);
}
},
[selectedResources, onResourcesSelected]
);
return (
<Column expand noMargin useFullHeight>
<LineStackLayout>
<Column expand noMargin>
<SearchBar
value={searchText}
onChange={setSearchText}
onRequestSearch={() => {}}
placeholder={t`Search resources`}
/>
</Column>
</LineStackLayout>
{!searchResults ? (
<PlaceholderLoader />
) : (
<ProjectResourcesList
project={project}
searchResults={searchResults}
selectedResources={selectedResources}
onSelectResource={onSelectResource}
/>
)}
</Column>
);
};
export default ProjectResourcesChooser;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import Resource3DPreviewContext from './Resource3DPreviewContext';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import CheckeredBackground from '../CheckeredBackground';
import { CorsAwareImage } from '../../UI/CorsAwareImage';
import { useIsMounted } from '../../Utils/UseIsMounted';
const styles = {
container: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
minHeight: 0,
},
background: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'url("res/transparentback.png") repeat',
},
screenshot: {
position: 'relative',
objectFit: 'contain',
width: '100%',
height: '100%',
},
loader: {
padding: 0,
},
};
type Props = {|
modelUrl: string,
expand?: boolean,
fullWidth?: boolean,
size?: number,
|};
const Model3DPreview = ({ modelUrl, size, expand, fullWidth }: Props) => {
const { getResourcePreview } = React.useContext(Resource3DPreviewContext);
const [imageDataUrl, setImageDataUrl] = React.useState(modelUrl ? null : '');
const isMounted = useIsMounted();
// Load the model preview when the component mounts or when the modelUrl changes.
React.useEffect(
() => {
(async () => {
if (!modelUrl) {
return;
}
const dataUrl = await getResourcePreview(modelUrl);
if (!isMounted.current) {
return;
}
setImageDataUrl(dataUrl);
})();
},
[modelUrl, getResourcePreview, isMounted]
);
return (
<div
style={{
...styles.container,
flex: expand ? 1 : undefined,
width: fullWidth ? '100%' : size,
height: size || '100%',
}}
>
<CheckeredBackground borderRadius={4} />
{imageDataUrl === null ? (
<PlaceholderLoader size={24} style={styles.loader} />
) : (
imageDataUrl !== '' && (
<CorsAwareImage
src={imageDataUrl}
alt="3D Model Preview"
style={{
...styles.screenshot,
maxWidth: size,
maxHeight: size,
}}
/>
)
)}
</div>
);
};
export default Model3DPreview;

View File

@@ -0,0 +1,228 @@
/* eslint-env worker */
// @flow
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
const isNativeMobileApp = false;
// Copied from Utils/CrossOrigin.js
const checkIfIsGDevelopCloudBucketUrl = url => {
return (
url.startsWith('https://project-resources.gdevelop.io/') ||
url.startsWith('https://project-resources-dev.gdevelop.io/')
);
};
// Copied from Utils/CrossOrigin.js
const checkIfCredentialsRequired = url => {
// Any resource stored on the GDevelop Cloud buckets needs credentials
if (isNativeMobileApp) return false;
if (checkIfIsGDevelopCloudBucketUrl(url)) return true;
// For other resources, use the default way of loading resources
return false;
};
// Copied from PixiResourcesLoader.js
const removeMetalness = material => {
if (material.metalness) {
material.metalness = 0;
}
};
// Copied from PixiResourcesLoader.js
const removeMetalnessFromMesh = node => {
if (!node.material) {
return;
}
if (Array.isArray(node.material)) {
for (let index = 0; index < node.material.length; index++) {
removeMetalness(node.material[index]);
}
} else {
removeMetalness(node.material);
}
};
// Worker message types
const MESSAGE_TYPES = {
RENDER_MODEL: 'RENDER_MODEL',
RENDER_COMPLETE: 'RENDER_COMPLETE',
RENDER_ERROR: 'RENDER_ERROR',
INIT: 'INIT',
};
let renderer = null;
let width = 256;
let height = 256;
let offscreenCanvas = null;
// Set up the renderer when worker is initialized
const initRenderer = () => {
// $FlowExpectedError - OffscreenCanvas is not in Flow types
offscreenCanvas = new OffscreenCanvas(width, height);
// Create renderer with offscreen canvas
renderer = new THREE.WebGLRenderer({
canvas: offscreenCanvas,
antialias: true,
alpha: true,
});
renderer.useLegacyLights = true; // Use legacy lights as in the editor.
renderer.setSize(width, height, false);
return true;
};
// Render a 3D model to the offscreen canvas and return the data URL
const renderModel = async resourceUrl => {
if (!renderer) {
throw new Error('Renderer not initialized');
}
const scene = new THREE.Scene();
const light = new THREE.HemisphereLight();
light.color = new THREE.Color(1, 1, 1);
light.groundColor = new THREE.Color(0.25, 0.25, 0.25);
light.position.set(0, 0, 1);
const lightGroup = new THREE.Group();
lightGroup.rotation.order = 'ZYX';
lightGroup.rotation.x = Math.PI / 4;
lightGroup.add(light);
scene.add(lightGroup);
// Load the model
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.withCredentials = checkIfCredentialsRequired(resourceUrl);
loader.load(
resourceUrl,
gltf => {
if (!renderer) {
throw new Error('Renderer not initialized');
}
if (!offscreenCanvas) {
throw new Error('Offscreen canvas not initialized');
}
const model = gltf.scene;
model.traverse(removeMetalnessFromMesh);
// We can't just rely on model.boundingBox because it doesn't take into account
// the model's scale and position.
// So we need to compute the bounding box from the meshes in the model.
const box = new THREE.Box3();
const meshes = [];
model.traverse(child => {
if (child.isMesh) {
meshes.push(child);
}
});
meshes.forEach(mesh => {
mesh.geometry.computeBoundingBox();
const geometryBox = mesh.geometry.boundingBox.clone();
geometryBox.applyMatrix4(mesh.matrixWorld);
box.union(geometryBox);
});
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1 / maxDim;
model.scale.set(scale, scale, scale);
// Center horizontally
model.position.x -= center.x * scale;
model.position.z -= center.z * scale;
// Slight upward shift so base isn't too low
model.position.y -= (center.y - size.y / 2) * scale;
scene.add(model);
const camera = new THREE.PerspectiveCamera(
45,
width / height,
0.1,
1000
);
// Those are empirical values to make the model fit nicely in the frame.
camera.position.set(
1 * (0.5 + size.x * scale),
1 * (0.5 + size.y * scale),
2 * (0.5 + size.z * scale)
);
// Aim camera slightly above center (based on size)
camera.lookAt(0, 0.5 * size.y * scale, 0);
// Render the scene
renderer.render(scene, camera);
// Get the screenshot and return it
const screenshot = offscreenCanvas.convertToBlob
? offscreenCanvas.convertToBlob().then(blob => {
return URL.createObjectURL(blob);
})
: Promise.resolve(renderer.domElement.toDataURL());
resolve(screenshot);
},
undefined,
error => {
reject(error);
}
);
});
};
// Handle messages from the main thread
// eslint-disable-next-line no-restricted-globals
self.onmessage = async event => {
const { type, resourceUrl } = event.data;
try {
switch (type) {
case MESSAGE_TYPES.INIT:
const success = initRenderer();
// eslint-disable-next-line no-restricted-globals
self.postMessage({ type: MESSAGE_TYPES.INIT, success });
break;
case MESSAGE_TYPES.RENDER_MODEL:
if (!renderer) {
throw new Error('Renderer not initialized');
}
const screenshot = await renderModel(resourceUrl);
// eslint-disable-next-line no-restricted-globals
self.postMessage({
type: MESSAGE_TYPES.RENDER_COMPLETE,
resourceUrl,
screenshot,
});
break;
default:
console.warn('Unknown message type:', type);
}
} catch (error) {
// eslint-disable-next-line no-restricted-globals
self.postMessage({
type: MESSAGE_TYPES.RENDER_ERROR,
resourceUrl,
error: error.message || 'Unknown error',
originalEvent: JSON.stringify(event.data),
});
}
};
// Required for Create React App to correctly bundle this worker
// eslint-disable-next-line import/no-anonymous-default-export
export default {};

View File

@@ -0,0 +1,267 @@
// @flow
import * as React from 'react';
// Import the worker (will be handled by worker-loader)
import Resource3DPreviewWorker from './Resource3DPreview.worker';
type WorkerInitMessage = {|
type: 'INIT',
|};
type WorkerRenderModelMessage = {|
type: 'RENDER_MODEL',
resourceUrl: string,
|};
type WorkerOutInitMessage = {|
type: 'INIT',
success: boolean,
|};
type WorkerOutRenderCompleteMessage = {|
type: 'RENDER_COMPLETE',
resourceUrl: string,
screenshot: string,
|};
type WorkerOutRenderErrorMessage = {|
type: 'RENDER_ERROR',
resourceUrl: string,
error: string,
|};
type WorkerOutMessage =
| WorkerOutInitMessage
| WorkerOutRenderCompleteMessage
| WorkerOutRenderErrorMessage;
export type Resource3DPreviewState = {|
getResourcePreview: (resourceUrl: string) => Promise<?string>,
|};
const initialResource3DPreviewState = {
getResourcePreview: async (_resourceUrl: string) => null,
};
const Resource3DPreviewContext = React.createContext<Resource3DPreviewState>(
initialResource3DPreviewState
);
export default Resource3DPreviewContext;
// Message types matching the worker's constants
const MESSAGE_TYPES = {
RENDER_MODEL: 'RENDER_MODEL',
RENDER_COMPLETE: 'RENDER_COMPLETE',
RENDER_ERROR: 'RENDER_ERROR',
INIT: 'INIT',
};
// Worker manager that handles initialization and communication
class Resource3DPreviewWorkerManager {
worker: Worker;
isInitialized: boolean = false;
pendingPromises: Map<
string,
{ resolve: (dataUrl: string) => void, reject: () => void }
> = new Map();
fallbackImagePath: string = 'JsPlatform/Extensions/3d_model.svg';
constructor() {
// $FlowExpectedError - worker-loader types aren't recognized by Flow
this.worker = new Resource3DPreviewWorker();
this.setupMessageHandlers();
this.initWorker();
}
setupMessageHandlers() {
this.worker.onmessage = (event: MessageEvent) => {
// $FlowExpectedError
const workerOutMessageData = (event.data: WorkerOutMessage);
const type = workerOutMessageData.type;
switch (type) {
case MESSAGE_TYPES.INIT:
const { success } =
// $FlowExpectedError
(workerOutMessageData: WorkerOutInitMessage);
this.isInitialized = success;
break;
case MESSAGE_TYPES.RENDER_COMPLETE:
const { resourceUrl, screenshot } =
// $FlowExpectedError
(workerOutMessageData: WorkerOutRenderCompleteMessage);
const pendingPromise = this.pendingPromises.get(resourceUrl);
if (pendingPromise) {
pendingPromise.resolve(screenshot);
this.pendingPromises.delete(resourceUrl);
}
break;
case MESSAGE_TYPES.RENDER_ERROR:
const { resourceUrl: errorResourceUrl, error } =
// $FlowExpectedError
(workerOutMessageData: WorkerOutRenderErrorMessage);
console.error('Worker error rendering 3D model:', error);
const pendingErrorPromise = this.pendingPromises.get(
errorResourceUrl
);
if (pendingErrorPromise) {
pendingErrorPromise.resolve(this.fallbackImagePath);
this.pendingPromises.delete(errorResourceUrl);
}
break;
default:
console.warn('Unknown message type from worker:', type);
break;
}
};
this.worker.onerror = error => {
console.error('Worker error:', error);
// Resolve any pending promises with the fallback image
this.pendingPromises.forEach(promise => {
promise.resolve(this.fallbackImagePath);
});
this.pendingPromises.clear();
};
}
initWorker() {
const message: WorkerInitMessage = { type: MESSAGE_TYPES.INIT };
this.worker.postMessage(message);
}
renderModel(resourceUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
resolve(this.fallbackImagePath);
return;
}
this.pendingPromises.set(resourceUrl, { resolve, reject });
const message: WorkerRenderModelMessage = {
type: MESSAGE_TYPES.RENDER_MODEL,
resourceUrl,
};
this.worker.postMessage(message);
});
}
terminate() {
if (this.worker) {
this.worker.terminate();
}
}
}
type Props = {|
children: React.Node,
|};
export const Resource3DPreviewProvider = ({ children }: Props) => {
const [currentResource, setCurrentResource] = React.useState<?string>(null);
const queueRef = React.useRef<
Array<{ url: string, resolve: (dataUrl: ?string) => void }>
>([]);
const previewCache = React.useRef<{ [url: string]: string }>({});
const workerManagerRef = React.useRef<?Resource3DPreviewWorkerManager>(null);
// Initialize the worker manager on mount
React.useEffect(() => {
workerManagerRef.current = new Resource3DPreviewWorkerManager();
// Cleanup on unmount
return () => {
queueRef.current = [];
previewCache.current = {};
// Terminate the worker
if (workerManagerRef.current) {
workerManagerRef.current.terminate();
workerManagerRef.current = null;
}
};
}, []);
const enqueueResource = React.useCallback((url: string): Promise<?string> => {
return new Promise(resolve => {
// If it's already in the cache, resolve immediately.
if (previewCache.current[url]) {
resolve(previewCache.current[url]);
return;
}
// Add the item to the queue.
queueRef.current.push({ url, resolve });
// If the queue didn't have items before,
// then process it immediately.
// Otherwise, let the queue process handle it.
if (queueRef.current.length === 1) {
setCurrentResource(url);
}
});
}, []);
const renderModel = React.useCallback(async (url: string) => {
if (!workerManagerRef.current) {
return null;
}
try {
const dataUrl = await workerManagerRef.current.renderModel(url);
return dataUrl;
} catch (error) {
console.error('Error rendering 3D model:', error);
return 'JsPlatform/Extensions/3d_model.svg';
}
}, []);
// Effect to process the current resource
React.useEffect(
() => {
if (!currentResource) return;
const processResource = async () => {
const dataUrl = await renderModel(currentResource);
// Handle the result
if (dataUrl) {
// Save it in the cache for future use
previewCache.current[currentResource] = dataUrl;
// Resolve all the requests made for that URL
const queueItemsToResolve = queueRef.current.filter(
item => item.url === currentResource
);
queueItemsToResolve.forEach(item => {
const { resolve } = item;
if (resolve) resolve(dataUrl);
});
// Remove the items from the queue
queueRef.current = queueRef.current.filter(
item => item.url !== currentResource
);
// And trigger the next item to be processed
const nextItemToProcess = queueRef.current[0];
if (nextItemToProcess) {
setCurrentResource(nextItemToProcess.url);
}
}
};
processResource();
},
[currentResource, renderModel]
);
return (
<Resource3DPreviewContext.Provider
value={{ getResourcePreview: enqueueResource }}
>
{children}
</Resource3DPreviewContext.Provider>
);
};

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