diff --git a/package-lock.json b/package-lock.json index 3eea05a9..173898d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "object-hash": "^2.0.3", "pinia": "^2.0.32", "python-shell": "^2.0.3", + "raw-loader": "^4.0.2", "remove-files-webpack-plugin": "^1.5.0", "sortablejs": "^1.15.0", "splitpanes": "^2.4.1", @@ -5617,7 +5618,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, "engines": { "node": "*" } @@ -7979,7 +7979,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, "engines": { "node": ">= 4" } @@ -11173,7 +11172,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -14971,6 +14969,58 @@ "node": ">=0.10.0" } }, + "node_modules/raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "license": "MIT", + "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/raw-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==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/raw-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index d2652b1e..15c2cf55 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "object-hash": "^2.0.3", "pinia": "^2.0.32", "python-shell": "^2.0.3", + "raw-loader": "^4.0.2", "remove-files-webpack-plugin": "^1.5.0", "sortablejs": "^1.15.0", "splitpanes": "^2.4.1", diff --git a/public/public_libraries/strype/graphics.py b/public/public_libraries/strype/graphics.py index 921cb1ec..04f86de9 100644 --- a/public/public_libraries/strype/graphics.py +++ b/public/public_libraries/strype/graphics.py @@ -5,754 +5,779 @@ import re as _re import time as _time -def in_bounds(x, y): - """ - Checks if the given X, Y position is in the visible bounds of (-399,-299) inclusive to (400, 300) exclusive. - - :param x: The x position to check - :param y: The y position to check - :return: A boolean indicating whether it is in the visible bounds: True if it is in bounds, False if it is not. - """ - return -399 <= x < 400 and -299 <= y < 300 +# This file is automatically processed to extract types for TigerPython +# Any function with a return type should be preceded at the +# same indent level by a comment beginning "#@@" followed by the type -class Actor: + +def _round_and_clamp_0_255(number): + return min(max(int(round(number)), 0), 255) + +class Color: """ - An Actor is an item in the world with a specific image, position, rotation and scale. Everything you want to show up - in your graphics must be an Actor. + A Color class with members red, green, blue, alpha, in the range 0--255. """ - # Private attributes: - # __id: the identifier of the PersistentImage that represents this actor on screen. Should never be None - # __editable_image: the editable image of this actor, if the user has ever called edit_image() on us. - # __tag: the user-supplied tag of the actor. Useful to leave the type flexible, we just pass it in and out. - # __say: the identifier of the PersistentImage with the current speech bubble for this actor. Is None when there is no current speech. - # Note that __say can be removed on the Javascript side without our code executing, due to a timeout. So - # whenever we use it, we should check it's still actually present. - - def __init__(self, image_or_filename, x = 0, y = 0, tag = None): + def __init__(self, red, green, blue, alpha = 255): """ - Construct an Actor with a given image and position and an optional name. - - Note: if you pass an EditableImage, this Actor will use a reference to it for its display. This means - if you make any changes to that EditableImage, it will update the Actor's image. If you pass - the same EditableImage to multiple Actors, they will all update when you edit it. If you do not want this - behaviour then call `make_copy()` on the EditableImage as you pass it in. - - Note: you can pass a filename for the image, which is an image name from Strype's image library, - or a URL to an image. Using a URL requires the server to allow remote image loading from Javascript via a feature - called CORS. Many servers do not allow this, so you may get an error even if the URL is valid and - you can load the image in a browser yourself. + Constructs a color value with the given red, green, blue and alpha values. If they are below 0 they will be treated + as if they were 0, and if they are above 255 they will be treated as if they were 255. Fractional numbers will + be converted to a whole number. - :param image_or_filename: Either a string with an image name (from Strype's built-in images), a string with a URL (e.g. "https://example.com/example.png") or an EditableImage - :param x: The X position at which to add the actor - :param y: The Y position at which to add the actor - :param tag: The tag to give the actor (for use in detecting touching actors) + :param red: The red value, from 0 (none) to 255 (most). + :param green: The green value, from 0 (none) to 255 (most). + :param blue: The blue value, from 0 (none) to 255 (most). + :param alpha: The alpha value. Alpha represents transparency. 0 means fully transparent which is rarely what you want. 255 means non-transparent. Values inbetween indicate the amount of transparency. """ - if isinstance(image_or_filename, EditableImage): - self.__id = _strype_graphics_internal.addImage(image_or_filename._EditableImage__image, self) - self.__editable_image = image_or_filename - elif isinstance(image_or_filename, str): - self.__id = _strype_graphics_internal.addImage(_strype_graphics_internal.loadAndWaitForImage(image_or_filename), self) - self.__editable_image = None - else: - raise TypeError("Actor constructor parameter must be string or EditableImage") - self.__say = None - self.__tag = tag - _strype_graphics_internal.setImageLocation(self.__id, x, y) - _strype_graphics_internal.setImageRotation(self.__id, 0) - - def set_location(self, x, y): + self.red = _round_and_clamp_0_255(red) + self.green = _round_and_clamp_0_255(green) + self.blue = _round_and_clamp_0_255(blue) + self.alpha = _round_and_clamp_0_255(alpha) + + #@@ str + def to_html(self): """ - Sets the position of the actor to be the given x, y position. - - If the position is outside the bounds of the world (X: -399 to +400, Y: -299 to +300) the position - will be adjusted to the nearest point inside the world. + Get the HTML version of this Color, in the format #RRGGBBAA where each pair is 2 hexadecimal digits. - :param x: The new X position of the actor - :param y: The new Y position of the actor + :return: The HTML version of this Color as string. """ - _strype_graphics_internal.setImageLocation(self.__id, x, y) - self._update_say_position() - - def set_rotation(self, deg): + r = _round_and_clamp_0_255(self.red) + g = _round_and_clamp_0_255(self.green) + b = _round_and_clamp_0_255(self.blue) + a = _round_and_clamp_0_255(self.alpha) + return "#{:02x}{:02x}{:02x}{:02x}".format(r, g, b, a) + +class Dimension: + """ + A dimension value indicating a width and a height, for example the size of an image. + """ + def __init__(self, width, height): """ - Sets the rotation of the actor to be the given rotation in degrees. This changes the rotation of - the actor's image and also affects which direction the actor will travel if you call `turn()`. + Constructs a dimension value with the given width and height. - :param deg: The rotation in degrees (0 points right, 90 points up, 180 points left, 270 points down). + :param width: The width. + :param height: The height. """ - _strype_graphics_internal.setImageRotation(self.__id, deg) - # Note: no need to update say position if we are just rotating - - def set_scale(self, scale): + self.width = width + self.height = height + +class EditableImage: + """ + An editable image of fixed width and height. + """ + + # Attributes: + # __image: A Javascript OffscreenCanvas, but from the Python end it is only + # passed back to Javascript calls. + + # Tracks the rate limiting for downloads: + __last_download = _time.time() + + + def __init__(self, width, height): """ - Sets the actor's scale (size multiplier). The default is 1, larger values make it bigger (for example, 2 is double size), - and smaller values make it smaller (for example, 0.5 is half size). It must be a positive number greater than zero. + Creates an editable image with the given dimensions, with transparent content. - :param scale: The new scale to set, replacing the old scale. + :param width: The width of the image in pixels + :param height: The height of the image in pixels """ - if scale <= 0: - raise ValueError("Scale must be greater than zero") - _strype_graphics_internal.setImageScale(self.__id, scale) - self._update_say_position() - - def get_rotation(self): + + # Note: for internal purposes we sometimes don't want to make an image, so we pass -1,-1 for that case: + if width > 0 and height > 0: + self.__image = _strype_graphics_internal.makeCanvasOfSize(width, height) + self.clear_rect(0, 0, width, height) + _strype_graphics_internal.canvas_setFill(self.__image, "white") + _strype_graphics_internal.canvas_setStroke(self.__image, "black") + else: + self.__image = None + + def fill(self): """ - Gets the current rotation of this Actor. - - Note: returns None if the actor has been removed by a call to remove(). - - :return: The rotation of this Actor, in degrees. + Fills the image with the current fill color (see `set_fill`) """ - return _strype_graphics_internal.getImageRotation(self.__id) - - def get_scale(self): + dim = _strype_graphics_internal.getCanvasDimensions(self.__image) + _strype_graphics_internal.canvas_fillRect(self.__image, 0, 0, dim[0], dim[1]) + + def set_fill(self, color): """ - Gets the current scale of this Actor. + Sets the current fill color for future fill operations (but does not do any filling). - Note: returns None if the actor has been removed by a call to remove(). + :param fill: A color that is either an HTML color name (e.g. "magenta"), an HTML hex string (e.g. "#ff00c0"), a :class:`Color` object, or None if you want to turn off filling + """ + if isinstance(color, Color): + _strype_graphics_internal.canvas_setFill(self.__image, color.to_html()) + elif isinstance(color, str) or color is None: + _strype_graphics_internal.canvas_setFill(self.__image, color) + else: + raise TypeError("Fill must be either a string or a Color but was " + str(type(color))) + + def set_stroke(self, color): + """ + Sets the current stroke/outline color for future shape-drawing operations (but does not draw anything). - :return: The scale of this Actor, where 1.0 is the default scale. + :param fill: A color that is either an HTML color name (e.g. "magenta"), an HTML hex string (e.g. "#ff00c0"), a :class:`Color` object, or None if you want to turn off the stroke """ - return _strype_graphics_internal.getImageScale(self.__id) - def get_tag(self): + if isinstance(color, Color): + _strype_graphics_internal.canvas_setStroke(self.__image, color.to_html()) + elif isinstance(color, str) or color is None: + _strype_graphics_internal.canvas_setStroke(self.__image, color) + else: + raise TypeError("Stroke must be either a string or a Color but was " + str(type(color))) + + #@@ Color + def get_pixel(self, x, y): """ - Gets the tag of this actor. + Gets a Color object with the color of the pixel at the given position. If you want to change the color, + you must call `set_pixel` rather than modifying the returned object. - :return: The tag of this actor, as passed to the constructor of the object. + :param x: The X position within the image, in pixels + :param y: The Y position within the image, in pixels + :return: A Color object with the color of the given pixel """ - return self.__tag - - def remove(self): + rgba = _strype_graphics_internal.canvas_getPixel(self.__image, int(x), int(y)) + return Color(rgba[0], rgba[1], rgba[2], rgba[3]) + + def set_pixel(self, x, y, color): """ - Removes the actor from the world. There is no way to re-add the actor to the world. + Sets the pixel at the given x, y position to be the given color. + + :param x: The x position of the pixel (must be an integer) + :param y: The y position of the pixel (must be an integer) + :param color: The color to set. This must be a :class:`Color` object. """ - _strype_graphics_internal.removeImage(self.__id) - # Also remove any speech bubble: - self.say("") - - def get_x(self): + _strype_graphics_internal.canvas_setPixel(self.__image, x, y, (color.red, color.green, color.blue, color.alpha)) + + #@@ list + def bulk_get_pixels(self): """ - Gets the X position of the actor as an integer (whole number). If the actors current position - is not a whole number, it is rounded down (towards zero). If you want the exact position as a potentially - fractional number, call `get_exact_x()` instead. - - Note: returns None if the actor has been removed by a call to remove(). + Gets the values of the pixels of the image in one large array. Index 0 in the array is the red value, + of the pixel at the top-left (0,0) in the image. Indexes 1, 2 and 3 are the green, blue and alpha of that pixel. + Index 4 is the red value of the pixel at (1, 0) in the image. So the values are sets of four (RGBA in that order) + for each pixel, and at the end of the first row it starts at the left of the second row. - :return: The current X position, rounded down to an integer (whole number). + :return: An array of 0-255 values organised as described above. """ - - # Gets X with rounding (towards zero): - location = _strype_graphics_internal.getImageLocation(self.__id) - return int(location['x']) if location else None - - def get_y(self): + return _strype_graphics_internal.canvas_getAllPixels(self.__image) + + def bulk_set_pixels(self, rgba_array): """ - Gets the Y position of the actor as an integer (whole number). If the actors current position - is not a whole number, it is rounded down (towards zero). If you want the exact position as a potentially - fractional number, call `get_exact_y()` instead. - - Note: returns None if the actor has been removed by a call to remove(). + Sets the values of the pixels from RGBA values in one giant array. The pixels should be arranged as described + in `bulk_get_pixels()`. The array should thus be of length width * height * 4. - :return: The current Y position, rounded down to an integer (whole number). + :param rgba_array: An array of 0-255 RGBA values organised as described above. """ - # Gets Y with rounding (towards zero): - location = _strype_graphics_internal.getImageLocation(self.__id) - return int(location['y']) if location else None - - def get_exact_x(self): + _strype_graphics_internal.canvas_setAllPixelsRGBA(self.__image, rgba_array) + + def clear_rect(self, x, y, width, height): """ - Gets the exact X position of the actor, which may be a fractional number. If you do not need this accuracy, - you may prefer to call `get_x()` instead. + Clears the given rectangle (i.e. sets all the pixels to be fully transparent). - Note: returns None if the actor has been removed by a call to remove(). - - :return: The exact X position - """ - # Gets X with no rounding: - location = _strype_graphics_internal.getImageLocation(self.__id) - return location['x'] if location else None - - def get_exact_y(self): - """ - Gets the exact Y position of the actor, which may be a fractional number. If you do not need this accuracy, - you may prefer to call `get_y()` instead. - - Note: returns None if the actor has been removed by a call to remove(). - - :return: The exact Y position + :param x: The left X coordinate of the rectangle (inclusive). + :param y: The top Y coordinate of the rectangle (inclusive). + :param width: The width of the rectangle + :param height: The height of the rectangle. """ - # Gets Y with no rounding: - location = _strype_graphics_internal.getImageLocation(self.__id) - return location['y'] if location else None - - def move(self, amount): + _strype_graphics_internal.canvas_clearRect(self.__image, x, y, width, height) + + def draw_image(self, image, x, y): """ - Move forwards the given amount in the current direction that the actor is heading. If you want to change - this direction, you can call `set_rotation()` or `turn()`. - - If the movement would take the actor outside the bounds of the world, the actor is moved to the nearest - point within the world; you cannot move outside the world. + Draws the entire given image into this image, at the given top-left x, y position. If you only want to draw + part of the image, use `draw_part_of_image()`. - :param amount: The amount of pixels to move forwards. Negative amounts move backwards. + :param image: The image to draw from, into this image. Must be an EditableImage. + :param x: The left X coordinate to draw the image at. + :param y: The top Y coordinate to draw the image at. """ - cur = _strype_graphics_internal.getImageLocation(self.__id) - if cur is not None: - rot = _math.radians(_strype_graphics_internal.getImageRotation(self.__id)) - self.set_location(cur['x'] + amount * _math.cos(rot), cur['y'] + amount * _math.sin(rot)) - # If cur is None, do nothing - def turn(self, degrees): + dim = _strype_graphics_internal.getCanvasDimensions(image._EditableImage__image) + _strype_graphics_internal.canvas_drawImagePart(self.__image, image._EditableImage__image, x, y, 0, 0, dim[0], dim[1]) + + def draw_part_of_image(self, image, x, y, sx, sy, width, height): """ - Changes the actor's current rotation by the given amount of degrees. + Draws part of the given image into this image. - :param degrees: The change in rotation, in degrees. Positive amounts turn anti-clockwise, negative amounts turn clockwise. + :param image: The image to draw from, into this image. Must be an EditableImage. + :param x: The left X coordinate to draw the image at. + :param y: The top Y coordinate to draw the image at. + :param sx: The left X coordinate within the source image to draw from. + :param sy: The top Y coordinate within the source image to draw from. + :param width: The width of the area to draw from. + :param height: The height of the area to draw from. """ - rotation = _strype_graphics_internal.getImageRotation(self.__id) - if rotation is not None: - self.set_rotation(rotation + degrees) - # If rotation is None, do nothing - - def is_at_edge(self): + _strype_graphics_internal.canvas_drawImagePart(self.__image, image._EditableImage__image, x, y, sx, sy, width, height) + + #@@ float + def get_width(self): """ - Checks whether the central point of the actor is at the edge of the screen. - - An actor is determined to be at the edge if it's position is within two pixels of the edge of the screen. - So if its X is less than -397 or greater than 398, or its Y is less than -297 or greater than 298. + Gets the width of this image. - :return: True if the actor is at the edge of the world, False otherwise. + :return: The width of this image, in pixels. """ - x = self.get_exact_x() - y = self.get_exact_y() - if x is None or y is None: - return False - return x < -397 or x > 398 or y < -297 or y > 298 - - def is_touching(self, actor_or_tag): + return _strype_graphics_internal.getCanvasDimensions(self.__image)[0] + + #@@ float + def get_height(self): """ - Checks if this actor is touching the given actor. Two actors are deemed to be touching if the - rectangles of their images are overlapping (even if the actor is transparent at that point). - - You can either pass an actor, or an actor's tag to check for collisions. If you pass a tag, - it will check whether any actor touching the current actor has that tag. - - Note that if either this actor or the given actor has had collisions turned off with - `set_can_touch(false)` then this function will return False even if they touch. + Gets the height of this image. - :param actor_or_tag: The actor (or tag of an actor) to check for overlap - :return: True if this actor overlaps that actor, False if it does not + :return: The height of this image, in pixels. """ - if isinstance(actor_or_tag, Actor): - return _strype_input_internal.checkCollision(self.__id, actor_or_tag.__id) - else: - # All other types are assumed to be a tag: - # Slightly odd construct but we convert list (implicitly boolean) to explicitly boolean: - return True if self.get_all_touching(actor_or_tag) else False + return _strype_graphics_internal.getCanvasDimensions(self.__image)[1] - def get_touching(self, tag = None): + def draw_text(self, text, x, y, font_size, max_width = 0, max_height = 0, font_family = None): """ - Gets the actor touching this one. If you pass a tag it will return a touching Actor - with that tag (or None if there is none) -- if there are many actors with that - tag it will return an arbitrary actor from the set. If you do not pass a tag, it will return an - arbitrary touching Actor (or None if there is none). - - Two actors are deemed to be touching if the - rectangles of their images are overlapping (even if the actor is transparent at that point). + Draws text on the editable image. You can specify an optional maximum width and maximum height. If you specify a max_width + greater than zero then the text will be wrapped at whitespace to try to fit it into the given width. If the text still doesn't + fit, or it doesn't fit in to max_height (where max_height is greater than 0), the font size will be progressively shrunk + (down to a minimum size of 8 pixels) to try to make it fit. But it is possible with awkward text (e.g. one long word + like "Aaaaaarrrghhhh!!") that it still may not fit in the given size. - Note that if either this actor (or the potentially-touching) actor has had collisions turned off with - `set_can_touch(false)` then this function will return None even if they appear to touch. + Note that text is colored using the fill (see `set_fill()`) not the stroke. Text drawing is done by filling the shape of the letters, + not outlining like a stencil. - :param tag: The tag of the actor to check for touching, or None to check all actors. - :return: The Actor we are touching, if any, otherwise None if we are not touching an Actor. + :param text: The text to draw + :param x: The x position of the top-left + :param y: The y position of the top-left + :param font_size: The size of the text to draw, in pixels + :param max_width: The maximum width of the text (or 0 if you do not want a maximum width) + :param max_height: The maximum height of the text (or 0 if you do not want a maximum height) + :param font_family: If None, then the default font family is used. To change this, pass your own FontFamily instance. """ - return next(iter(self.get_all_touching(tag)), None) - - def set_can_touch(self, can_touch): + if font_family is not None and not isinstance(font_family, FontFamily): + raise TypeError("Font family must be an instance of FontFamily") + dim = _strype_graphics_internal.canvas_drawText(self.__image, text, x, y, font_size, max_width, max_height, font_family._FontFamily__font if font_family is not None else None) + return Dimension(dim['width'], dim['height']) + def rounded_rectangle(self, x, y, width, height, corner_size): """ - Changes whether the actor is part of the collision detection system. - - If you turn it off then this actor will never show up in the collision checking. - You may want to do this if you have an actor which makes no sense to collide (such - as a score board, or game over text), and/or to speed up the simulation for actors - where you don't need collision detection (e.g. visual effects). + Draws a rectangle with rounded corners. The edge of the rectangle is drawn in the current outline color + (see `set_outline`) and filled in the current fill color (see `set_fill`). The corners are rounded using + quarter-circles with radius of `corner_size`. - :param can_touch: Whether this actor can participate in collisions. + :param x: The top-left of the rounded rectangle. + :param y: The bottom-right of the rounded rectangle. + :param width: The width of the rounded rectangle. + :param height: The height of the rounded rectangle. + :param corner_size: The radius of the corners of the rounded rectangle. """ - _strype_input_internal.setCollidable(self.__id, can_touch) - - def get_all_touching(self, tag = None): + _strype_graphics_internal.canvas_roundedRect(self.__image, x, y, width, height, corner_size) + def rectangle(self, x, y, width, height): """ - Gets all the actors that this actor is touching. If this actor has had `set_can_touch(false)` - called, the returned list will always be empty. The list will never feature any actors - which have had `set_can_touch(false)` called on them. - - If the tag is given (i.e. is not None), it will be used to filter the returned list just - to actors with that given tag. + Draws a rectangle. The edge of the rectangle is drawn in the current stroke color + (see `set_stroke`) and filled in the current fill color (see `set_fill`). + + :param x: The top-left of the rounded rectangle. + :param y: The bottom-right of the rounded rectangle. + :param width: The width of the rounded rectangle. + :param height: The height of the rounded rectangle. + """ + _strype_graphics_internal.canvas_roundedRect(self.__image, x, y, width, height, 0) + def line(self, start_x, start_y, end_x, end_y): + """ + Draws a line. The line is drawn in the current stroke color. - :param tag: The tag to use to filter the returned actors (or None/omitted if you do not want to filter the actors by tag) - :return: A list of all touching actors. + :param start_x: The starting X position. + :param start_y: The starting Y position. + :param end_x: The end X position. + :param end_y: The end Y position. """ - return [a for a in _strype_input_internal.getAllTouchingAssociated(self.__id) if tag is None or tag == a.get_tag()] - - def remove_touching(self, tag = None): + _strype_graphics_internal.canvas_line(self.__image, start_x, start_y, end_x, end_y) + def arc(self, centre_x, centre_y, width, height, angle_start, angle_amount): """ - Removes one arbitrary touching actor. If you pass a tag, it will only remove touching actors with the - given tag. + Draws an arc (a part of an ellipse, an ellipse being a circle with a width than can be different than height). + Imagine an ellipse with a given centre position and width and height. The `angle_start` parameter + is the angle from the centre to the start of the arc, in degrees (0 points to the right, positive values go clockwise), + and the `angle_amount` is the amount of degrees to travel (positive goes clockwise, negative goes anti-clockwise) to + the end point. - Note that if either this actor (or the potentially-touching) actor has had collisions turned off with - `set_can_touch(false)` then this function will not remove the other actor, even if they appear to touch. + The arc will be filled with the current fill (see `set_fill()`) and drawn in the current stroke (see `set_stroke()`). - :param tag: The name to use to filter the removed actor (or None/omitted if you do not want to filter the actors by tag) + :param centre_x: The centre X position of the arc. + :param centre_y: The centre Y position of the arc. + :param width: The width of the ellipse that describes the arc. + :param height: The height of the ellipse that describes the arc. + :param angle_start: The starting angle of the arc, in degrees (0 points to the right). + :param angle_amount: The amount of degrees to travel (positive goes clockwise). """ - a = self.get_touching(tag) - if a is not None: - a.remove() + _strype_graphics_internal.canvas_arc(self.__image, centre_x, centre_y, width, height, angle_start, angle_amount) - def edit_image(self): + #@@ EditableImage + def make_copy(self): """ - Return an EditableImage which can be used to edit this actor's image. All modifications - to the returned image will be shown for this actor automatically. If you call this function multiple times - you will get the same EditableImage returned. + Makes a copy of this EditableImage with the same width and height, + and the same image content. - :return: An EditableImage with the current Actor image already drawn in it + :return: The new copy of the EditableImage """ - # Note: we don't want to have an editable image by default because it is slower to render - # the editable canvas than to render the unedited image (I think!?) - if self.__editable_image is None: - # The -1, -1 sizing indicates we will set the image ourselves afterwards: - self.__editable_image = EditableImage(-1, -1) - self.__editable_image._EditableImage__image = _strype_graphics_internal.makeImageEditable(self.__id) - return self.__editable_image - - def say(self, text, font_size = 20, max_width = 300, max_height = 200, font_family = None): + copy = EditableImage(self.get_width(), self.get_height()) + copy.draw_image(self, 0, 0) + return copy + + def download(self, filename="strype-image"): """ - Add a speech bubble next to the actor with the given text. The only required parameter is the - text, all the others can be omitted. The text will be wrapped if it reaches max_width (unless you - set max_width to 0). If it then overflows max_height, the font size will be reduced until the text fits - in both max_width and max_height. Wrapping will only occur at spaces, so if you have long text like - "Aaaaaarrrggghhhh" and want it to wrap you may need to add a space in there. + Triggers a download of this image as a PNG image file. You can optionally + pass a file name (you do not need to include the file extension, Strype + will add that automatically). To help you distinguish downloads + from repeated runs, Strype will automatically add a timestamp to the file. - To remove the speech bubble later, call `say("")` (that is, with a blank string). You can also consider - using `say_for` if you want the speech to display for a fixed time. + To avoid problems with accidentally calling this method too often, Strype + will limit the rate of downloads to at most one every 2 seconds. - :param text: The text to be displayed in the speech bubble. You can use \\n to separate lines. - :param font_size: The font size to try to display at - :param max_width: The maximum width to fit the speech into (excluding padding which is added to make the speech bubble) - :param max_height: The maximum height to fit the speech into (excluding padding which is added to make the speech bubble) + :param filename: The main part of the filename to use for the downloaded file. """ - - # Remove any existing speech bubble: - if self.__say is not None and _strype_graphics_internal.imageExists(self.__say): - _strype_graphics_internal.removeImage(self.__say) - self.__say = None - # Then add a new one if text is not blank and we are in the world: - if text and _strype_graphics_internal.imageExists(self.__id): - padding = 10 - # We first make an image just with the text on, which also tells us the size: - textOnlyImg = EditableImage(max_width, max_height) - textOnlyImg.set_fill("white") - textOnlyImg.fill() - textOnlyImg.set_fill("black") - textDimensions = textOnlyImg.draw_text(text, 0, 0, font_size, max_width, max_height, font_family) - # Now we prepare an image of the right size plus padding: - sayImg = EditableImage(textDimensions.width + 2 * padding, textDimensions.height + 2 * padding) - # We draw a rounded rect for the background, then draw the text on: - sayImg.set_fill("white") - sayImg.set_stroke("#555555FF") - sayImg.rounded_rectangle(0, 0, textDimensions.width + 2 * padding, textDimensions.height + 2 * padding, padding) - sayImg.draw_part_of_image(textOnlyImg, padding, padding, 0, 0, textDimensions.width, textDimensions.height) - self.__say = _strype_graphics_internal.addImage(sayImg._EditableImage__image, None) - self._update_say_position() - - def _update_say_position(self): - # Update the speech bubble position to be relative to our new position and scale: - if self.__say is not None and _strype_graphics_internal.imageExists(self.__say): - say_dim = _strype_graphics_internal.getImageSize(self.__say) - our_dim = _strype_graphics_internal.getImageSize(self.__id) - scale = _strype_graphics_internal.getImageScale(self.__id) - width = our_dim['width'] * scale - height = our_dim['height'] * scale - # Based on where speech bubbles generally appear, we try the following in order: - placements = [ - [1, 1], # Above right - [-1, 1], # Above left - [0, 1], # Above centered - [1, 0], # Right - [-1, 0], # Left - [1, -1], # Below right - [-1, -1],# Below left - [-1, 0], # Below - [0, 0], # Centered - ] - for p in placements: - # Note, we halve the width/height of the actor because we're going from centre of actor, - # but we do not halve the width/height of the say here because we want to see if the whole bubble fits: - fits = in_bounds(self.get_x() + p[0]*(width/2 + say_dim['width']), self.get_y() + p[1]*(height/2 + say_dim['height'])) - # If it fits or its our last fallback: - if fits or p == [0,0] : - # Here we do halve both widths/heights because we are placing the centre: - _strype_graphics_internal.setImageLocation(self.__say, self.get_x() + p[0]*(width/2 + say_dim['width']/2), self.get_y() + p[1]*(height/2 + say_dim['height']/2)) - break - else: - self.__say = None + # We add a kind of rate limiter for downloads. This is not necessary from a technical perspective, + # but imagine the user accidentally puts their download inside a tight loop; they may trigger the + # download of 100 files before they realised what has happened. I'm not sure if browsers will + # protect against this. So we protect against this by limiting downloads to only happening every + # 2 seconds. It's easier to do this on the Python side than on the Javascript side (where we'd have + # to mess with promises and Skulpt suspensions. This is already wrapped up into the Python time + # module anyway: + now = _time.time() + # If it's less than 2 seconds since last download, wait: + if now < EditableImage.__last_download + 2: + _time.sleep(EditableImage.__last_download + 2 - now) + _strype_graphics_internal.canvas_downloadPNG(self.__image, filename) + EditableImage.__last_download = _time.time() - def say_for(self, text, seconds, font_size = 16, max_width = 300, max_height = 200): +class FontFamily: + """ + A font family is a particular font type, e.g. Arial or Courier. + """ + def __init__(self, font_provider, font_name): """ - Like the `say` function, but automatically removes the speech bubble after the given number of seconds. For all - other parameters, see the `say` function for an explanation. + Loads the given font name from the given font provider. At the moment, the only font provider which is supported is + "google", meaning `Google Fonts `. So if you find a particular font you like on Google Fonts, say Roboto, you can load it + by calling: - Any other calls to `say()` or `say_for()` will override the current timed removal. - - :param text: The text to display in the speech bubble - :param seconds: The number of seconds to display it for. - :param font_size: See `say` - :param max_width: See `say` - :param max_height: See `say` + .. code-block:: python + FontFamily("google", "Roboto") + + If the font cannot be loaded, you will get an error. This usually indicates either an issue with your Internet connection, or that you have entered the font name wrongly. + + :param font_provider: The provider of the fonts. Currently only "google" is supported. + :param font_name: The name of the font to load, as shown on that provider. """ - self.say(text, font_size, max_width, max_height) - _strype_graphics_internal.removeImageAfter(self.__say, seconds) + if not _strype_graphics_internal.canvas_loadFont(font_provider, font_name): + raise Exception("Could not load font " + font_name) + self.__font = font_name -def _round_and_clamp_0_255(number): - return min(max(int(round(number)), 0), 255) +#@@ bool +def in_bounds(x, y): + """ + Checks if the given X, Y position is in the visible bounds of (-399,-299) inclusive to (400, 300) exclusive. + + :param x: The x position to check + :param y: The y position to check + :return: A boolean indicating whether it is in the visible bounds: True if it is in bounds, False if it is not. + """ + return -399 <= x < 400 and -299 <= y < 300 -class Color: +class Actor: """ - A Color class with members red, green, blue, alpha, in the range 0--255. + An Actor is an item in the world with a specific image, position, rotation and scale. Everything you want to show up + in your graphics must be an Actor. """ - def __init__(self, red, green, blue, alpha = 255): + # Private attributes: + # __id: the identifier of the PersistentImage that represents this actor on screen. Should never be None + # __editable_image: the editable image of this actor, if the user has ever called edit_image() on us. + # __tag: the user-supplied tag of the actor. Useful to leave the type flexible, we just pass it in and out. + # __say: the identifier of the PersistentImage with the current speech bubble for this actor. Is None when there is no current speech. + # Note that __say can be removed on the Javascript side without our code executing, due to a timeout. So + # whenever we use it, we should check it's still actually present. + + def __init__(self, image_or_filename, x = 0, y = 0, tag = None): """ - Constructs a color value with the given red, green, blue and alpha values. If they are below 0 they will be treated - as if they were 0, and if they are above 255 they will be treated as if they were 255. Fractional numbers will - be converted to a whole number. + Construct an Actor with a given image and position and an optional name. - :param red: The red value, from 0 (none) to 255 (most). - :param green: The green value, from 0 (none) to 255 (most). - :param blue: The blue value, from 0 (none) to 255 (most). - :param alpha: The alpha value. Alpha represents transparency. 0 means fully transparent which is rarely what you want. 255 means non-transparent. Values inbetween indicate the amount of transparency. - """ - self.red = _round_and_clamp_0_255(red) - self.green = _round_and_clamp_0_255(green) - self.blue = _round_and_clamp_0_255(blue) - self.alpha = _round_and_clamp_0_255(alpha) + Note: if you pass an EditableImage, this Actor will use a reference to it for its display. This means + if you make any changes to that EditableImage, it will update the Actor's image. If you pass + the same EditableImage to multiple Actors, they will all update when you edit it. If you do not want this + behaviour then call `make_copy()` on the EditableImage as you pass it in. - def to_html(self): - """ - Get the HTML version of this Color, in the format #RRGGBBAA where each pair is 2 hexadecimal digits. + Note: you can pass a filename for the image, which is an image name from Strype's image library, + or a URL to an image. Using a URL requires the server to allow remote image loading from Javascript via a feature + called CORS. Many servers do not allow this, so you may get an error even if the URL is valid and + you can load the image in a browser yourself. - :return: The HTML version of this Color as string. - """ - r = _round_and_clamp_0_255(self.red) - g = _round_and_clamp_0_255(self.green) - b = _round_and_clamp_0_255(self.blue) - a = _round_and_clamp_0_255(self.alpha) - return "#{:02x}{:02x}{:02x}{:02x}".format(r, g, b, a) - -class Dimension: - """ - A dimension value indicating a width and a height, for example the size of an image. - """ - def __init__(self, width, height): + :param image_or_filename: Either a string with an image name (from Strype's built-in images), a string with a URL (e.g. "https://example.com/example.png") or an EditableImage + :param x: The X position at which to add the actor + :param y: The Y position at which to add the actor + :param tag: The tag to give the actor (for use in detecting touching actors) """ - Constructs a dimension value with the given width and height. + if isinstance(image_or_filename, EditableImage): + self.__id = _strype_graphics_internal.addImage(image_or_filename._EditableImage__image, self) + self.__editable_image = image_or_filename + elif isinstance(image_or_filename, str): + self.__id = _strype_graphics_internal.addImage(_strype_graphics_internal.loadAndWaitForImage(image_or_filename), self) + self.__editable_image = None + else: + raise TypeError("Actor constructor parameter must be string or EditableImage") + self.__say = None + self.__tag = tag + _strype_graphics_internal.setImageLocation(self.__id, x, y) + _strype_graphics_internal.setImageRotation(self.__id, 0) - :param width: The width. - :param height: The height. + def set_location(self, x, y): """ - self.width = width - self.height = height - -class EditableImage: - """ - An editable image of fixed width and height. - """ - - # Attributes: - # __image: A Javascript OffscreenCanvas, but from the Python end it is only - # passed back to Javascript calls. - - # Tracks the rate limiting for downloads: - __last_download = _time.time() + Sets the position of the actor to be the given x, y position. - - def __init__(self, width, height): - """ - Creates an editable image with the given dimensions, with transparent content. + If the position is outside the bounds of the world (X: -399 to +400, Y: -299 to +300) the position + will be adjusted to the nearest point inside the world. - :param width: The width of the image in pixels - :param height: The height of the image in pixels + :param x: The new X position of the actor + :param y: The new Y position of the actor """ + _strype_graphics_internal.setImageLocation(self.__id, x, y) + self._update_say_position() - # Note: for internal purposes we sometimes don't want to make an image, so we pass -1,-1 for that case: - if width > 0 and height > 0: - self.__image = _strype_graphics_internal.makeCanvasOfSize(width, height) - self.clear_rect(0, 0, width, height) - _strype_graphics_internal.canvas_setFill(self.__image, "white") - _strype_graphics_internal.canvas_setStroke(self.__image, "black") - else: - self.__image = None - - def fill(self): - """ - Fills the image with the current fill color (see `set_fill`) + def set_rotation(self, deg): """ - dim = _strype_graphics_internal.getCanvasDimensions(self.__image) - _strype_graphics_internal.canvas_fillRect(self.__image, 0, 0, dim[0], dim[1]) + Sets the rotation of the actor to be the given rotation in degrees. This changes the rotation of + the actor's image and also affects which direction the actor will travel if you call `turn()`. - def set_fill(self, color): + :param deg: The rotation in degrees (0 points right, 90 points up, 180 points left, 270 points down). """ - Sets the current fill color for future fill operations (but does not do any filling). + _strype_graphics_internal.setImageRotation(self.__id, deg) + # Note: no need to update say position if we are just rotating - :param fill: A color that is either an HTML color name (e.g. "magenta"), an HTML hex string (e.g. "#ff00c0"), a :class:`Color` object, or None if you want to turn off filling + def set_scale(self, scale): """ - if isinstance(color, Color): - _strype_graphics_internal.canvas_setFill(self.__image, color.to_html()) - elif isinstance(color, str) or color is None: - _strype_graphics_internal.canvas_setFill(self.__image, color) - else: - raise TypeError("Fill must be either a string or a Color but was " + str(type(color))) + Sets the actor's scale (size multiplier). The default is 1, larger values make it bigger (for example, 2 is double size), + and smaller values make it smaller (for example, 0.5 is half size). It must be a positive number greater than zero. - def set_stroke(self, color): + :param scale: The new scale to set, replacing the old scale. """ - Sets the current stroke/outline color for future shape-drawing operations (but does not draw anything). + if scale <= 0: + raise ValueError("Scale must be greater than zero") + _strype_graphics_internal.setImageScale(self.__id, scale) + self._update_say_position() - :param fill: A color that is either an HTML color name (e.g. "magenta"), an HTML hex string (e.g. "#ff00c0"), a :class:`Color` object, or None if you want to turn off the stroke + #@@ float + def get_rotation(self): """ - if isinstance(color, Color): - _strype_graphics_internal.canvas_setStroke(self.__image, color.to_html()) - elif isinstance(color, str) or color is None: - _strype_graphics_internal.canvas_setStroke(self.__image, color) - else: - raise TypeError("Stroke must be either a string or a Color but was " + str(type(color))) + Gets the current rotation of this Actor. - def get_pixel(self, x, y): - """ - Gets a Color object with the color of the pixel at the given position. If you want to change the color, - you must call `set_pixel` rather than modifying the returned object. + Note: returns None if the actor has been removed by a call to remove(). - :param x: The X position within the image, in pixels - :param y: The Y position within the image, in pixels - :return: A Color object with the color of the given pixel + :return: The rotation of this Actor, in degrees. """ - rgba = _strype_graphics_internal.canvas_getPixel(self.__image, int(x), int(y)) - return Color(rgba[0], rgba[1], rgba[2], rgba[3]) - - def set_pixel(self, x, y, color): + return _strype_graphics_internal.getImageRotation(self.__id) + + #@@ float + def get_scale(self): """ - Sets the pixel at the given x, y position to be the given color. - - :param x: The x position of the pixel (must be an integer) - :param y: The y position of the pixel (must be an integer) - :param color: The color to set. This must be a :class:`Color` object. - """ - _strype_graphics_internal.canvas_setPixel(self.__image, x, y, (color.red, color.green, color.blue, color.alpha)) + Gets the current scale of this Actor. - def bulk_get_pixels(self): - """ - Gets the values of the pixels of the image in one large array. Index 0 in the array is the red value, - of the pixel at the top-left (0,0) in the image. Indexes 1, 2 and 3 are the green, blue and alpha of that pixel. - Index 4 is the red value of the pixel at (1, 0) in the image. So the values are sets of four (RGBA in that order) - for each pixel, and at the end of the first row it starts at the left of the second row. + Note: returns None if the actor has been removed by a call to remove(). - :return: An array of 0-255 values organised as described above. + :return: The scale of this Actor, where 1.0 is the default scale. """ - return _strype_graphics_internal.canvas_getAllPixels(self.__image) + return _strype_graphics_internal.getImageScale(self.__id) - def bulk_set_pixels(self, rgba_array): + def get_tag(self): """ - Sets the values of the pixels from RGBA values in one giant array. The pixels should be arranged as described - in `bulk_get_pixels()`. The array should thus be of length width * height * 4. + Gets the tag of this actor. - :param rgba_array: An array of 0-255 RGBA values organised as described above. + :return: The tag of this actor, as passed to the constructor of the object. """ - _strype_graphics_internal.canvas_setAllPixelsRGBA(self.__image, rgba_array) - - def clear_rect(self, x, y, width, height): + return self.__tag + + def remove(self): """ - Clears the given rectangle (i.e. sets all the pixels to be fully transparent). + Removes the actor from the world. There is no way to re-add the actor to the world. + """ + _strype_graphics_internal.removeImage(self.__id) + # Also remove any speech bubble: + self.say("") + + #@@ float + def get_x(self): + """ + Gets the X position of the actor as an integer (whole number). If the actors current position + is not a whole number, it is rounded down (towards zero). If you want the exact position as a potentially + fractional number, call `get_exact_x()` instead. - :param x: The left X coordinate of the rectangle (inclusive). - :param y: The top Y coordinate of the rectangle (inclusive). - :param width: The width of the rectangle - :param height: The height of the rectangle. + Note: returns None if the actor has been removed by a call to remove(). + + :return: The current X position, rounded down to an integer (whole number). """ - _strype_graphics_internal.canvas_clearRect(self.__image, x, y, width, height) - def draw_image(self, image, x, y): + # Gets X with rounding (towards zero): + location = _strype_graphics_internal.getImageLocation(self.__id) + return int(location['x']) if location else None + + #@@ float + def get_y(self): """ - Draws the entire given image into this image, at the given top-left x, y position. If you only want to draw - part of the image, use `draw_part_of_image()`. + Gets the Y position of the actor as an integer (whole number). If the actors current position + is not a whole number, it is rounded down (towards zero). If you want the exact position as a potentially + fractional number, call `get_exact_y()` instead. - :param image: The image to draw from, into this image. Must be an EditableImage. - :param x: The left X coordinate to draw the image at. - :param y: The top Y coordinate to draw the image at. + Note: returns None if the actor has been removed by a call to remove(). + + :return: The current Y position, rounded down to an integer (whole number). """ - dim = _strype_graphics_internal.getCanvasDimensions(image._EditableImage__image) - _strype_graphics_internal.canvas_drawImagePart(self.__image, image._EditableImage__image, x, y, 0, 0, dim[0], dim[1]) + # Gets Y with rounding (towards zero): + location = _strype_graphics_internal.getImageLocation(self.__id) + return int(location['y']) if location else None + + #@@ float + def get_exact_x(self): + """ + Gets the exact X position of the actor, which may be a fractional number. If you do not need this accuracy, + you may prefer to call `get_x()` instead. - def draw_part_of_image(self, image, x, y, sx, sy, width, height): + Note: returns None if the actor has been removed by a call to remove(). + + :return: The exact X position """ - Draws part of the given image into this image. + # Gets X with no rounding: + location = _strype_graphics_internal.getImageLocation(self.__id) + return location['x'] if location else None + + #@@ float + def get_exact_y(self): + """ + Gets the exact Y position of the actor, which may be a fractional number. If you do not need this accuracy, + you may prefer to call `get_y()` instead. - :param image: The image to draw from, into this image. Must be an EditableImage. - :param x: The left X coordinate to draw the image at. - :param y: The top Y coordinate to draw the image at. - :param sx: The left X coordinate within the source image to draw from. - :param sy: The top Y coordinate within the source image to draw from. - :param width: The width of the area to draw from. - :param height: The height of the area to draw from. + Note: returns None if the actor has been removed by a call to remove(). + + :return: The exact Y position """ - _strype_graphics_internal.canvas_drawImagePart(self.__image, image._EditableImage__image, x, y, sx, sy, width, height) + # Gets Y with no rounding: + location = _strype_graphics_internal.getImageLocation(self.__id) + return location['y'] if location else None + + def move(self, distance): + """ + Move forwards the given amount in the current direction that the actor is heading. If you want to change + this direction, you can call `set_rotation()` or `turn()`. - def get_width(self): + If the movement would take the actor outside the bounds of the world, the actor is moved to the nearest + point within the world; you cannot move outside the world. + + :param distance: The amount of pixels to move forwards. Negative amounts move backwards. """ - Gets the width of this image. + cur = _strype_graphics_internal.getImageLocation(self.__id) + if cur is not None: + rot = _math.radians(_strype_graphics_internal.getImageRotation(self.__id)) + self.set_location(cur['x'] + distance * _math.cos(rot), cur['y'] + distance * _math.sin(rot)) + # If cur is None, do nothing + def turn(self, degrees): + """ + Changes the actor's current rotation by the given amount of degrees. - :return: The width of this image, in pixels. + :param degrees: The change in rotation, in degrees. Positive amounts turn anti-clockwise, negative amounts turn clockwise. """ - return _strype_graphics_internal.getCanvasDimensions(self.__image)[0] - - def get_height(self): + rotation = _strype_graphics_internal.getImageRotation(self.__id) + if rotation is not None: + self.set_rotation(rotation + degrees) + # If rotation is None, do nothing + + #@@ bool + def is_at_edge(self): """ - Gets the height of this image. + Checks whether the central point of the actor is at the edge of the screen. - :return: The height of this image, in pixels. + An actor is determined to be at the edge if it's position is within two pixels of the edge of the screen. + So if its X is less than -397 or greater than 398, or its Y is less than -297 or greater than 298. + + :return: True if the actor is at the edge of the world, False otherwise. """ - return _strype_graphics_internal.getCanvasDimensions(self.__image)[1] - - def draw_text(self, text, x, y, font_size, max_width = 0, max_height = 0, font_family = None): + x = self.get_exact_x() + y = self.get_exact_y() + if x is None or y is None: + return False + return x < -397 or x > 398 or y < -297 or y > 298 + + #@@ bool + def is_touching(self, actor_or_tag): """ - Draws text on the editable image. You can specify an optional maximum width and maximum height. If you specify a max_width - greater than zero then the text will be wrapped at whitespace to try to fit it into the given width. If the text still doesn't - fit, or it doesn't fit in to max_height (where max_height is greater than 0), the font size will be progressively shrunk - (down to a minimum size of 8 pixels) to try to make it fit. But it is possible with awkward text (e.g. one long word - like "Aaaaaarrrghhhh!!") that it still may not fit in the given size. + Checks if this actor is touching the given actor. Two actors are deemed to be touching if the + rectangles of their images are overlapping (even if the actor is transparent at that point). - Note that text is colored using the fill (see `set_fill()`) not the stroke. Text drawing is done by filling the shape of the letters, - not outlining like a stencil. + You can either pass an actor, or an actor's tag to check for collisions. If you pass a tag, + it will check whether any actor touching the current actor has that tag. - :param text: The text to draw - :param x: The x position of the top-left - :param y: The y position of the top-left - :param font_size: The size of the text to draw, in pixels - :param max_width: The maximum width of the text (or 0 if you do not want a maximum width) - :param max_height: The maximum height of the text (or 0 if you do not want a maximum height) - :param font_family: If None, then the default font family is used. To change this, pass your own FontFamily instance. + Note that if either this actor or the given actor has had collisions turned off with + `set_can_touch(false)` then this function will return False even if they touch. + + :param actor_or_tag: The actor (or tag of an actor) to check for overlap + :return: True if this actor overlaps that actor, False if it does not """ - if font_family is not None and not isinstance(font_family, FontFamily): - raise TypeError("Font family must be an instance of FontFamily") - dim = _strype_graphics_internal.canvas_drawText(self.__image, text, x, y, font_size, max_width, max_height, font_family._FontFamily__font if font_family is not None else None) - return Dimension(dim['width'], dim['height']) - def rounded_rectangle(self, x, y, width, height, corner_size): + if isinstance(actor_or_tag, Actor): + return _strype_input_internal.checkCollision(self.__id, actor_or_tag.__id) + else: + # All other types are assumed to be a tag: + # Slightly odd construct but we convert list (implicitly boolean) to explicitly boolean: + return True if self.get_all_touching(actor_or_tag) else False + + #@@ Actor + def get_touching(self, tag = None): """ - Draws a rectangle with rounded corners. The edge of the rectangle is drawn in the current outline color - (see `set_outline`) and filled in the current fill color (see `set_fill`). The corners are rounded using - quarter-circles with radius of `corner_size`. + Gets the actor touching this one. If you pass a tag it will return a touching Actor + with that tag (or None if there is none) -- if there are many actors with that + tag it will return an arbitrary actor from the set. If you do not pass a tag, it will return an + arbitrary touching Actor (or None if there is none). - :param x: The top-left of the rounded rectangle. - :param y: The bottom-right of the rounded rectangle. - :param width: The width of the rounded rectangle. - :param height: The height of the rounded rectangle. - :param corner_size: The radius of the corners of the rounded rectangle. + Two actors are deemed to be touching if the + rectangles of their images are overlapping (even if the actor is transparent at that point). + + Note that if either this actor (or the potentially-touching) actor has had collisions turned off with + `set_can_touch(false)` then this function will return None even if they appear to touch. + + :param tag: The tag of the actor to check for touching, or None to check all actors. + :return: The Actor we are touching, if any, otherwise None if we are not touching an Actor. """ - _strype_graphics_internal.canvas_roundedRect(self.__image, x, y, width, height, corner_size) - def rectangle(self, x, y, width, height): + return next(iter(self.get_all_touching(tag)), None) + + def set_can_touch(self, can_touch): """ - Draws a rectangle. The edge of the rectangle is drawn in the current stroke color - (see `set_stroke`) and filled in the current fill color (see `set_fill`). - - :param x: The top-left of the rounded rectangle. - :param y: The bottom-right of the rounded rectangle. - :param width: The width of the rounded rectangle. - :param height: The height of the rounded rectangle. + Changes whether the actor is part of the collision detection system. + + If you turn it off then this actor will never show up in the collision checking. + You may want to do this if you have an actor which makes no sense to collide (such + as a score board, or game over text), and/or to speed up the simulation for actors + where you don't need collision detection (e.g. visual effects). + + :param can_touch: Whether this actor can participate in collisions. """ - _strype_graphics_internal.canvas_roundedRect(self.__image, x, y, width, height, 0) - def line(self, start_x, start_y, end_x, end_y): + _strype_input_internal.setCollidable(self.__id, can_touch) + + #@@ list + def get_all_touching(self, tag = None): """ - Draws a line. The line is drawn in the current stroke color. + Gets all the actors that this actor is touching. If this actor has had `set_can_touch(false)` + called, the returned list will always be empty. The list will never feature any actors + which have had `set_can_touch(false)` called on them. - :param start_x: The starting X position. - :param start_y: The starting Y position. - :param end_x: The end X position. - :param end_y: The end Y position. + If the tag is given (i.e. is not None), it will be used to filter the returned list just + to actors with that given tag. + + :param tag: The tag to use to filter the returned actors (or None/omitted if you do not want to filter the actors by tag) + :return: A list of all touching actors. """ - _strype_graphics_internal.canvas_line(self.__image, start_x, start_y, end_x, end_y) - def arc(self, centre_x, centre_y, width, height, angle_start, angle_amount): + return [a for a in _strype_input_internal.getAllTouchingAssociated(self.__id) if tag is None or tag == a.get_tag()] + + def remove_touching(self, tag = None): """ - Draws an arc (a part of an ellipse, an ellipse being a circle with a width than can be different than height). - Imagine an ellipse with a given centre position and width and height. The `angle_start` parameter - is the angle from the centre to the start of the arc, in degrees (0 points to the right, positive values go clockwise), - and the `angle_amount` is the amount of degrees to travel (positive goes clockwise, negative goes anti-clockwise) to - the end point. + Removes one arbitrary touching actor. If you pass a tag, it will only remove touching actors with the + given tag. - The arc will be filled with the current fill (see `set_fill()`) and drawn in the current stroke (see `set_stroke()`). + Note that if either this actor (or the potentially-touching) actor has had collisions turned off with + `set_can_touch(false)` then this function will not remove the other actor, even if they appear to touch. - :param centre_x: The centre X position of the arc. - :param centre_y: The centre Y position of the arc. - :param width: The width of the ellipse that describes the arc. - :param height: The height of the ellipse that describes the arc. - :param angle_start: The starting angle of the arc, in degrees (0 points to the right). - :param angle_amount: The amount of degrees to travel (positive goes clockwise). + :param tag: The name to use to filter the removed actor (or None/omitted if you do not want to filter the actors by tag) """ - _strype_graphics_internal.canvas_arc(self.__image, centre_x, centre_y, width, height, angle_start, angle_amount) + a = self.get_touching(tag) + if a is not None: + a.remove() - def make_copy(self): + #@@ EditableImage + def edit_image(self): """ - Makes a copy of this EditableImage with the same width and height, - and the same image content. + Return an EditableImage which can be used to edit this actor's image. All modifications + to the returned image will be shown for this actor automatically. If you call this function multiple times + you will get the same EditableImage returned. - :return: The new copy of the EditableImage + :return: An EditableImage with the current Actor image already drawn in it """ - copy = EditableImage(self.get_width(), self.get_height()) - copy.draw_image(self, 0, 0) - return copy + # Note: we don't want to have an editable image by default because it is slower to render + # the editable canvas than to render the unedited image (I think!?) + if self.__editable_image is None: + # The -1, -1 sizing indicates we will set the image ourselves afterwards: + self.__editable_image = EditableImage(-1, -1) + self.__editable_image._EditableImage__image = _strype_graphics_internal.makeImageEditable(self.__id) + return self.__editable_image - def download(self, filename="strype-image"): + def say(self, text, font_size = 20, max_width = 300, max_height = 200, font_family = None): """ - Triggers a download of this image as a PNG image file. You can optionally - pass a file name (you do not need to include the file extension, Strype - will add that automatically). To help you distinguish downloads - from repeated runs, Strype will automatically add a timestamp to the file. + Add a speech bubble next to the actor with the given text. The only required parameter is the + text, all the others can be omitted. The text will be wrapped if it reaches max_width (unless you + set max_width to 0). If it then overflows max_height, the font size will be reduced until the text fits + in both max_width and max_height. Wrapping will only occur at spaces, so if you have long text like + "Aaaaaarrrggghhhh" and want it to wrap you may need to add a space in there. - To avoid problems with accidentally calling this method too often, Strype - will limit the rate of downloads to at most one every 2 seconds. + To remove the speech bubble later, call `say("")` (that is, with a blank string). You can also consider + using `say_for` if you want the speech to display for a fixed time. - :param filename: The main part of the filename to use for the downloaded file. + :param text: The text to be displayed in the speech bubble. You can use \\n to separate lines. + :param font_size: The font size to try to display at + :param max_width: The maximum width to fit the speech into (excluding padding which is added to make the speech bubble) + :param max_height: The maximum height to fit the speech into (excluding padding which is added to make the speech bubble) """ - # We add a kind of rate limiter for downloads. This is not necessary from a technical perspective, - # but imagine the user accidentally puts their download inside a tight loop; they may trigger the - # download of 100 files before they realised what has happened. I'm not sure if browsers will - # protect against this. So we protect against this by limiting downloads to only happening every - # 2 seconds. It's easier to do this on the Python side than on the Javascript side (where we'd have - # to mess with promises and Skulpt suspensions. This is already wrapped up into the Python time - # module anyway: - now = _time.time() - # If it's less than 2 seconds since last download, wait: - if now < EditableImage.__last_download + 2: - _time.sleep(EditableImage.__last_download + 2 - now) - _strype_graphics_internal.canvas_downloadPNG(self.__image, filename) - EditableImage.__last_download = _time.time() + + # Remove any existing speech bubble: + if self.__say is not None and _strype_graphics_internal.imageExists(self.__say): + _strype_graphics_internal.removeImage(self.__say) + self.__say = None + # Then add a new one if text is not blank and we are in the world: + if text and _strype_graphics_internal.imageExists(self.__id): + padding = 10 + # We first make an image just with the text on, which also tells us the size: + textOnlyImg = EditableImage(max_width, max_height) + textOnlyImg.set_fill("white") + textOnlyImg.fill() + textOnlyImg.set_fill("black") + textDimensions = textOnlyImg.draw_text(text, 0, 0, font_size, max_width, max_height, font_family) + # Now we prepare an image of the right size plus padding: + sayImg = EditableImage(textDimensions.width + 2 * padding, textDimensions.height + 2 * padding) + # We draw a rounded rect for the background, then draw the text on: + sayImg.set_fill("white") + sayImg.set_stroke("#555555FF") + sayImg.rounded_rectangle(0, 0, textDimensions.width + 2 * padding, textDimensions.height + 2 * padding, padding) + sayImg.draw_part_of_image(textOnlyImg, padding, padding, 0, 0, textDimensions.width, textDimensions.height) + self.__say = _strype_graphics_internal.addImage(sayImg._EditableImage__image, None) + self._update_say_position() + + def _update_say_position(self): + # Update the speech bubble position to be relative to our new position and scale: + if self.__say is not None and _strype_graphics_internal.imageExists(self.__say): + say_dim = _strype_graphics_internal.getImageSize(self.__say) + our_dim = _strype_graphics_internal.getImageSize(self.__id) + scale = _strype_graphics_internal.getImageScale(self.__id) + width = our_dim['width'] * scale + height = our_dim['height'] * scale + # Based on where speech bubbles generally appear, we try the following in order: + placements = [ + [1, 1], # Above right + [-1, 1], # Above left + [0, 1], # Above centered + [1, 0], # Right + [-1, 0], # Left + [1, -1], # Below right + [-1, -1],# Below left + [-1, 0], # Below + [0, 0], # Centered + ] + for p in placements: + # Note, we halve the width/height of the actor because we're going from centre of actor, + # but we do not halve the width/height of the say here because we want to see if the whole bubble fits: + fits = in_bounds(self.get_x() + p[0]*(width/2 + say_dim['width']), self.get_y() + p[1]*(height/2 + say_dim['height'])) + # If it fits or its our last fallback: + if fits or p == [0,0] : + # Here we do halve both widths/heights because we are placing the centre: + _strype_graphics_internal.setImageLocation(self.__say, self.get_x() + p[0]*(width/2 + say_dim['width']/2), self.get_y() + p[1]*(height/2 + say_dim['height']/2)) + break + else: + self.__say = None -class FontFamily: - """ - A font family is a particular font type, e.g. Arial or Courier. - """ - def __init__(self, font_provider, font_name): + def say_for(self, text, seconds, font_size = 16, max_width = 300, max_height = 200): """ - Loads the given font name from the given font provider. At the moment, the only font provider which is supported is - "google", meaning `Google Fonts `. So if you find a particular font you like on Google Fonts, say Roboto, you can load it - by calling: + Like the `say` function, but automatically removes the speech bubble after the given number of seconds. For all + other parameters, see the `say` function for an explanation. - .. code-block:: python - FontFamily("google", "Roboto") - - If the font cannot be loaded, you will get an error. This usually indicates either an issue with your Internet connection, or that you have entered the font name wrongly. - - :param font_provider: The provider of the fonts. Currently only "google" is supported. - :param font_name: The name of the font to load, as shown on that provider. + Any other calls to `say()` or `say_for()` will override the current timed removal. + + :param text: The text to display in the speech bubble + :param seconds: The number of seconds to display it for. + :param font_size: See `say` + :param max_width: See `say` + :param max_height: See `say` """ - if not _strype_graphics_internal.canvas_loadFont(font_provider, font_name): - raise Exception("Could not load font " + font_name) - self.__font = font_name + self.say(text, font_size, max_width, max_height) + _strype_graphics_internal.removeImageAfter(self.__say, seconds) +#@@ EditableImage def load_image(filename): """ Loads the given image file as an EditableImage object. @@ -769,6 +794,7 @@ def load_image(filename): img._EditableImage__image = _strype_graphics_internal.htmlImageToCanvas(_strype_graphics_internal.loadAndWaitForImage(filename)) return img +#@@ Actor def get_clicked_actor(): """ Gets the last clicked Actor (or None if nothing was clicked since the last call to this function). Be careful that if you call this twice @@ -779,6 +805,7 @@ def get_clicked_actor(): """ return _strype_input_internal.getAndResetClickedItem() +#@@ bool def key_pressed(keyname): """ Checks if the given key is currently pressed. Note that because the user may be pressing and releasing keys all the time, diff --git a/scripts/add-docs-from-python.py b/scripts/add-docs-from-python.py index 1b560050..97337ce9 100644 --- a/scripts/add-docs-from-python.py +++ b/scripts/add-docs-from-python.py @@ -10,6 +10,9 @@ import sys from operator import attrgetter +sys.path.append("../public/public_libraries") +sys.path.append("./stubs") + def parse_arguments(text, func_name): # Split the text into lines lines = text.splitlines() diff --git a/scripts/stubs/strype_graphics_input_internal.py b/scripts/stubs/strype_graphics_input_internal.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/stubs/strype_graphics_internal.py b/scripts/stubs/strype_graphics_internal.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/stubs/strype_sound_internal.py b/scripts/stubs/strype_sound_internal.py new file mode 100644 index 00000000..e69de29b diff --git a/src/autocompletion/acManager.ts b/src/autocompletion/acManager.ts index 06eafc0a..308def4f 100644 --- a/src/autocompletion/acManager.ts +++ b/src/autocompletion/acManager.ts @@ -9,6 +9,47 @@ import {getMatchingBracket} from "@/helpers/editor"; import {getAllEnabledUserDefinedFunctions} from "@/helpers/storeMethods"; import i18n from "@/i18n"; import {OUR_PUBLIC_LIBRARY_MODULES} from "@/autocompletion/ac-skulpt"; +import {TPyParser} from "tigerpython-parser"; +import graphicsMod from "../../public/public_libraries/strype/graphics.py"; + +TPyParser.defineModule("strype.graphics", extractTypes(graphicsMod)); + +function removeDefaultParams(funcSignature: string): string { + // Regular expression to match parameters with default values + const regex = /(\s*,?\s*\w+\s*=\s*[^,)\s]+)/g; + // Replace matches with an empty string and clean up trailing commas or spaces + return funcSignature.replace(regex, "").replace(/\(\s*,/, "(").replace(/,\s*\)/, ")"); +} + +function extractTypes(original : string) : string { + const originalLines = original.split("\n"); + // We need to find everything starting class or def + const r = []; + let inClass = false; + for (let i = 0; i < originalLines.length; i++) { + if (originalLines[i].match(/^class.*:\s*$/)) { + r.push(originalLines[i]); + inClass = true; + } + else { + const fm = originalLines[i].match(/^(\s*)def\s+(.*)\s*:\s*$/); + if (fm && (fm[1] === "" || inClass)) { + let signature; + if (i > 0 && originalLines[i - 1].startsWith((fm[1] + "#@@"))) { + signature = fm[1] + "[" + originalLines[i - 1].slice(fm[1].length + 3).trim() + "]" + removeDefaultParams(fm[2]); + } + else { + signature = fm[1] + removeDefaultParams(fm[2]); + } + r.push(signature); + if (fm[1] === "") { + inClass = false; + } + } + } + } + return r.join("\n"); +} // Given a FieldSlot, get the program code corresponding to it, to use // as the prefix (context) for code completion. diff --git a/src/autocompletion/index.d.ts b/src/autocompletion/index.d.ts new file mode 100644 index 00000000..eb73950a --- /dev/null +++ b/src/autocompletion/index.d.ts @@ -0,0 +1,4 @@ +declare module "*.py" { + let _: string; + export default _; +} diff --git a/src/autocompletion/skulpt-api.json b/src/autocompletion/skulpt-api.json index e870913f..4fa67744 100644 --- a/src/autocompletion/skulpt-api.json +++ b/src/autocompletion/skulpt-api.json @@ -1547,7 +1547,7 @@ }, { "name": "verbosity", - "defaultValue": "" + "defaultValue": "" }, { "name": "failfast", @@ -1673,7 +1673,7 @@ }, { "name": "data", - "defaultValue": "" + "defaultValue": "" }, { "name": "timeout", @@ -2280,7 +2280,7 @@ }, { "name": "deepcopy", - "defaultValue": "" + "defaultValue": "" } ] }, @@ -2322,7 +2322,7 @@ }, { "name": "deepcopy", - "defaultValue": "" + "defaultValue": "" } ] }, @@ -2350,7 +2350,7 @@ }, { "name": "deepcopy", - "defaultValue": "" + "defaultValue": "" } ] }, @@ -2392,7 +2392,7 @@ }, { "name": "state", - "defaultValue": "" + "defaultValue": "" }, { "name": "listiter", @@ -14073,7 +14073,28 @@ "function", "type" ], - "version": 0 + "version": 0, + "params": [ + { + "name": "self", + "hide": true + }, + { + "name": "image_or_filename" + }, + { + "name": "x", + "defaultValue": "None" + }, + { + "name": "y", + "defaultValue": "0" + }, + { + "name": "tag", + "defaultValue": "0" + } + ] }, { "acResult": "Color", @@ -14082,7 +14103,26 @@ "function", "type" ], - "version": 0 + "version": 0, + "params": [ + { + "name": "self", + "hide": true + }, + { + "name": "red" + }, + { + "name": "green" + }, + { + "name": "blue" + }, + { + "name": "alpha", + "defaultValue": "255" + } + ] }, { "acResult": "Dimension", @@ -14091,7 +14131,19 @@ "function", "type" ], - "version": 0 + "version": 0, + "params": [ + { + "name": "self", + "hide": true + }, + { + "name": "width" + }, + { + "name": "height" + } + ] }, { "acResult": "EditableImage", @@ -14100,7 +14152,19 @@ "function", "type" ], - "version": 0 + "version": 0, + "params": [ + { + "name": "self", + "hide": true + }, + { + "name": "width" + }, + { + "name": "height" + } + ] }, { "acResult": "FontFamily", @@ -14109,7 +14173,19 @@ "function", "type" ], - "version": 0 + "version": 0, + "params": [ + { + "name": "self", + "hide": true + }, + { + "name": "font_provider" + }, + { + "name": "font_name" + } + ] }, { "acResult": "__doc__", @@ -14125,7 +14201,7 @@ }, { "acResult": "__loader__", - "documentation": "", + "documentation": "Concrete implementation of SourceLoader using the file system.", "type": [], "version": 0 }, @@ -14143,13 +14219,13 @@ }, { "acResult": "__spec__", - "documentation": "", + "documentation": "The specification for a module, used for loading.\n\nA module's spec is the source for information about the module. For\ndata associated with the module, including source, use the spec's\nloader.\n\n`name` is the absolute name of the module. `loader` is the loader\nto use when loading the module. `parent` is the name of the\npackage the module is in. The parent is derived from the name.\n\n`is_package` determines if the module is considered a package or\nnot. On modules this is reflected by the `__path__` attribute.\n\n`origin` is the specific location used by the loader from which to\nload the module, if that information is available. When filename is\nset, origin will match.\n\n`has_location` indicates that a spec's \"origin\" reflects a location.\nWhen this is True, `__file__` attribute of the module is set.\n\n`cached` is the location of the cached bytecode file, if any. It\ncorresponds to the `__cached__` attribute.\n\n`submodule_search_locations` is the sequence of path entries to\nsearch when importing submodules. If set, is_package should be\nTrue--and False otherwise.\n\nPackages are simply modules that (may) have submodules. If a spec\nhas a non-None value in `submodule_search_locations`, the import\nsystem will consider modules loaded from the spec as packages.\n\nOnly finders (see importlib.abc.MetaPathFinder and\nimportlib.abc.PathEntryFinder) should modify ModuleSpec instances.", "type": [], "version": 0 }, { "acResult": "_collections", - "documentation": "", + "documentation": "This module implements specialized container datatypes providing\nalternatives to Python's general purpose built-in containers, dict,\nlist, set, and tuple.\n\n* namedtuple factory function for creating tuple subclasses with named fields\n* deque list-like container with fast appends and pops on either end\n* ChainMap dict-like class for creating a single view of multiple mappings\n* Counter dict subclass for counting hashable objects\n* OrderedDict dict subclass that remembers the order entries were added\n* defaultdict dict subclass that calls a factory function to supply missing values\n* UserDict wrapper around dictionary objects for easier dict subclassing\n* UserList wrapper around list objects for easier list subclassing\n* UserString wrapper around string objects for easier string subclassing", "type": [ "module" ], @@ -14163,7 +14239,7 @@ }, { "acResult": "_math", - "documentation": "", + "documentation": "This module provides access to the mathematical functions\ndefined by the C standard.", "type": [ "module" ], @@ -14171,7 +14247,7 @@ }, { "acResult": "_re", - "documentation": "", + "documentation": "Support for regular expressions (RE).\n\nThis module provides regular expression matching operations similar to\nthose found in Perl. It supports both 8-bit and Unicode strings; both\nthe pattern and the strings being processed can contain null bytes and\ncharacters outside the US ASCII range.\n\nRegular expressions can contain both special and ordinary characters.\nMost ordinary characters, like \"A\", \"a\", or \"0\", are the simplest\nregular expressions; they simply match themselves. You can\nconcatenate ordinary characters, so last matches the string 'last'.\n\nThe special characters are:\n \".\" Matches any character except a newline.\n \"^\" Matches the start of the string.\n \"$\" Matches the end of the string or just before the newline at\n the end of the string.\n \"*\" Matches 0 or more (greedy) repetitions of the preceding RE.\n Greedy means that it will match as many repetitions as possible.\n \"+\" Matches 1 or more (greedy) repetitions of the preceding RE.\n \"?\" Matches 0 or 1 (greedy) of the preceding RE.\n *?,+?,?? Non-greedy versions of the previous three special characters.\n {m,n} Matches from m to n repetitions of the preceding RE.\n {m,n}? Non-greedy version of the above.\n \"\\\\\" Either escapes special characters or signals a special sequence.\n [] Indicates a set of characters.\n A \"^\" as the first character indicates a complementing set.\n \"|\" A|B, creates an RE that will match either A or B.\n (...) Matches the RE inside the parentheses.\n The contents can be retrieved or matched later in the string.\n (?aiLmsux) The letters set the corresponding flags defined below.\n (?:...) Non-grouping version of regular parentheses.\n (?P...) The substring matched by the group is accessible by name.\n (?P=name) Matches the text matched earlier by the group named name.\n (?#...) A comment; ignored.\n (?=...) Matches if ... matches next, but doesn't consume the string.\n (?!...) Matches if ... doesn't match next.\n (?<=...) Matches if preceded by ... (must be fixed length).\n (? { }); }); }); + +describe("Graphics library", () => { + if (Cypress.env("mode") == "microbit") { + // No graphics support in microbit mode: + return; + } + it("Shows completions for graphics standalone functions", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, "strype.graphics", "load_image(filename)"); + checkExactlyOneItem(acIDSel, "strype.graphics", "stop()"); + checkExactlyOneItem(acIDSel, "strype.graphics", "pause()"); + checkNoItems(acIDSel, "__name__"); + // Shouldn't show methods from Actor at top-level: + checkNoItems(acIDSel, "is_at_edge()"); + checkNoItems(acIDSel, "remove()"); + }); + }); + + it("Shows completions for object constructor", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, "strype.graphics", "Actor(image_or_filename)"); + }); + }); + + it("Shows completions for return of graphics load_image", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("load_image('a').{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, null, "get_width()"); + }); + }); + + it("Shows completions for Actor methods", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Make an actor: + cy.get("body").type("=a=Actor('cat-test.jpg'){rightarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("a.{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, null, "is_at_edge()"); + checkExactlyOneItem(acIDSel, null, "move(distance)"); + checkExactlyOneItem(acIDSel, null, "get_all_touching()"); + checkExactlyOneItem(acIDSel, null, "set_location(x, y)"); + checkNoItems(acIDSel, "__name__"); + // Shouldn't show methods from top-level: + checkNoItems(acIDSel, "stop()"); + checkNoItems(acIDSel, "pause()"); + }); + }); + + it("Shows completions for EditableImage methods", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Make an image: + cy.get("body").type("=e=EditableImage(100, 100){rightarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("e.{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, null, "get_width()"); + checkExactlyOneItem(acIDSel, null, "fill()"); + // Shouldn't show methods from top-level: + checkNoItems(acIDSel, "stop()"); + checkNoItems(acIDSel, "pause()"); + }); + }); + + it("Shows completions for EditableImage methods on make_copy()", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Make an image: + cy.get("body").type("=e=EditableImage(100, 100){rightarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("e.make_copy().{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, null, "get_width()"); + checkExactlyOneItem(acIDSel, null, "fill()"); + // Shouldn't show methods from top-level: + checkNoItems(acIDSel, "stop()"); + checkNoItems(acIDSel, "pause()"); + }); + }); + + it("Shows completions for EditableImage methods on Actor.edit_image()", () => { + focusEditorAC(); + // Add graphics import: + cy.get("body").type("{uparrow}{uparrow}fstrype.graphics{rightarrow}*{rightarrow}{downarrow}{downarrow}"); + // Make an image: + cy.get("body").type("=a=Actor('blah'){rightarrow}"); + // Add a function frame and trigger auto-complete: + cy.get("body").type(" "); + cy.wait(500); + cy.get("body").type("a.edit_image().{ctrl} "); + withAC((acIDSel, frameId) => { + cy.get(acIDSel).should("be.visible"); + checkExactlyOneItem(acIDSel, null, "get_width()"); + checkExactlyOneItem(acIDSel, null, "fill()"); + // Shouldn't show methods from top-level: + checkNoItems(acIDSel, "stop()"); + checkNoItems(acIDSel, "pause()"); + }); + }); +}); diff --git a/vue.config.js b/vue.config.js index 65a75cbb..90433297 100644 --- a/vue.config.js +++ b/vue.config.js @@ -34,6 +34,9 @@ const configureWebpackExtraProps = module.exports = { configureWebpack: { devtool: "source-map", + resolve: { + extensions: [ ".ts", ".js", ".py" ], + }, ...configureWebpackExtraProps, // allows pinia to compile fine (https://github.com/vuejs/pinia/issues/675) module: { @@ -43,6 +46,10 @@ module.exports = { include: /node_modules/, type: "javascript/auto", }, + { + test: /\.py$/, + use: "raw-loader", + }, ], }, },