mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
38 Commits
v5.5.228
...
rework-get
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3625acf975 | ||
![]() |
b3fa34ce3c | ||
![]() |
1bdb4c0369 | ||
![]() |
2822fab5ed | ||
![]() |
658ac381fb | ||
![]() |
80cf54cb1b | ||
![]() |
2f56f6b715 | ||
![]() |
9784113574 | ||
![]() |
e2de3bec34 | ||
![]() |
e837df4882 | ||
![]() |
2f44dab18b | ||
![]() |
10049ce42a | ||
![]() |
8d9a60f819 | ||
![]() |
8ea8c421b2 | ||
![]() |
f7b026f1cc | ||
![]() |
623535f7fd | ||
![]() |
3f0ff4a9de | ||
![]() |
883ca6d535 | ||
![]() |
77f56829b3 | ||
![]() |
12a842e197 | ||
![]() |
7145e6d049 | ||
![]() |
caa18e5fcb | ||
![]() |
61bf8a7cab | ||
![]() |
98b3687157 | ||
![]() |
4b4fba2c7a | ||
![]() |
cedb9429c7 | ||
![]() |
d844b4d380 | ||
![]() |
e4265553e0 | ||
![]() |
2524b0b9d2 | ||
![]() |
20d16b8a57 | ||
![]() |
67aa1ce062 | ||
![]() |
1759dda870 | ||
![]() |
03dce1d90a | ||
![]() |
4e9556e948 | ||
![]() |
a02b8dcfe0 | ||
![]() |
416ef44ee1 | ||
![]() |
aa90621899 | ||
![]() |
3d3f04f63e |
@@ -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",
|
||||
|
@@ -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();
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
@@ -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);
|
||||
});
|
||||
});
|
||||
|
@@ -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(
|
||||
|
@@ -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 {
|
||||
|
@@ -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);
|
||||
|
@@ -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(
|
||||
|
@@ -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 {
|
||||
|
@@ -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',
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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)'
|
||||
|
@@ -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;
|
||||
};
|
||||
|
||||
|
@@ -262,6 +262,7 @@ for (const [
|
||||
attributes.push(
|
||||
`${attributeName}${optionalReturn ? '?' : ''}: ${returnType};`
|
||||
);
|
||||
Parser.skipWhitespaces();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@@ -1,8 +1,8 @@
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
[](https://gdevelop.io/games)
|
||||
[](https://gdevelop.io/games)
|
||||
|
||||
## Technical architecture
|
||||
|
||||
|
29
newIDE/app/config-overrides.js
Normal file
29
newIDE/app/config-overrides.js
Normal 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;
|
||||
},
|
||||
};
|
62
newIDE/app/package-lock.json
generated
62
newIDE/app/package-lock.json
generated
@@ -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,
|
||||
|
@@ -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)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
3
newIDE/app/public/external/piskel/README.md
vendored
3
newIDE/app/public/external/piskel/README.md
vendored
@@ -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.
|
||||
|
@@ -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');
|
||||
|
@@ -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).
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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}>
|
||||
|
91
newIDE/app/src/Course/CourseChapterTitle.js
Normal file
91
newIDE/app/src/Course/CourseChapterTitle.js
Normal 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;
|
@@ -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>
|
||||
|
||||
<Text noMargin>
|
||||
<Trans>Chapter</Trans>
|
||||
</Text>
|
||||
-
|
||||
<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;
|
@@ -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)}
|
||||
•
|
||||
<Trans>{course.durationInWeeks} weeks</Trans>
|
||||
</Text>
|
||||
{renderProgress()}
|
||||
</Column>
|
||||
) : (
|
||||
<LineStackLayout noMargin alignItems="center" expand>
|
||||
{renderProgress()}
|
||||
<Text color="secondary" noMargin>
|
||||
{selectMessageByLocale(i18n, course.levelByLocale)}
|
||||
•
|
||||
<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)}
|
||||
•
|
||||
<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)}
|
||||
•
|
||||
<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>
|
||||
)}
|
||||
|
210
newIDE/app/src/Course/LockedCourseChapterPreview.js
Normal file
210
newIDE/app/src/Course/LockedCourseChapterPreview.js
Normal 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;
|
102
newIDE/app/src/Course/TextBasedCourseChapterItems.js
Normal file
102
newIDE/app/src/Course/TextBasedCourseChapterItems.js
Normal 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;
|
124
newIDE/app/src/Course/TextBasedCourseChapterTaskItem.js
Normal file
124
newIDE/app/src/Course/TextBasedCourseChapterTaskItem.js
Normal 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;
|
138
newIDE/app/src/Course/TextBasedCourseChapterView.js
Normal file
138
newIDE/app/src/Course/TextBasedCourseChapterView.js
Normal 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>
|
||||
|
||||
<Text noMargin>
|
||||
<Trans>Chapter</Trans>
|
||||
</Text>
|
||||
-
|
||||
{template.title && (
|
||||
<Text noMargin>{template.title} </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;
|
@@ -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;
|
214
newIDE/app/src/Course/VideoBasedCourseChapterView.js
Normal file
214
newIDE/app/src/Course/VideoBasedCourseChapterView.js
Normal 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>
|
||||
|
||||
<Text noMargin>
|
||||
<Trans>Chapter</Trans>
|
||||
</Text>
|
||||
-
|
||||
<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;
|
@@ -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>
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -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 = {|
|
@@ -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';
|
||||
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -9,8 +9,10 @@ a.conceptLink {
|
||||
}
|
||||
|
||||
a.noValidLink {
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.conceptLink span {
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -23,6 +23,7 @@ export const Toolbar = ({
|
||||
<ToolbarGroup firstChild>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="default"
|
||||
tooltip={t`View history`}
|
||||
onClick={onOpenHistory}
|
||||
>
|
||||
|
@@ -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) {
|
||||
|
@@ -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
|
||||
|
@@ -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',
|
||||
|
@@ -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;
|
@@ -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 GDevelop’s 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;
|
@@ -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;
|
@@ -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]);
|
||||
};
|
||||
|
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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}
|
||||
|
@@ -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;
|
@@ -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 : (
|
||||
|
@@ -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">
|
||||
|
@@ -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}
|
||||
/>
|
||||
|
@@ -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>
|
||||
|
@@ -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,
|
||||
|
@@ -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}
|
||||
|
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
@@ -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',
|
||||
},
|
||||
{
|
||||
|
@@ -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>
|
||||
|
@@ -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,
|
||||
|
@@ -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 => ({
|
||||
|
@@ -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>
|
||||
|
@@ -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'
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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(),
|
||||
|
@@ -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()}
|
||||
|
@@ -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]);
|
||||
}}
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -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) {
|
||||
|
@@ -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 ? (
|
||||
|
@@ -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>
|
||||
|
@@ -227,8 +227,6 @@ const ProfileDialog = ({ onClose }: Props) => {
|
||||
<UserAchievements
|
||||
achievements={authenticatedUser.achievements}
|
||||
badges={authenticatedUser.badges}
|
||||
displayUnclaimedAchievements
|
||||
displayNotifications
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@@ -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: "''",
|
||||
|
@@ -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(
|
||||
|
@@ -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 = () => {
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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;
|
@@ -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;
|
@@ -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 {};
|
@@ -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
Reference in New Issue
Block a user