├── .editorconfig ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── images.d.ts ├── package.json ├── public ├── index.html └── manifest.json ├── roblox └── RadialImage.lua ├── src ├── components │ ├── App.test.tsx │ ├── App.tsx │ ├── Canvas.tsx │ ├── Generator.css │ ├── Generator.tsx │ └── SheetRenderer.tsx ├── constants.ts ├── index.css ├── index.tsx ├── logo.svg ├── registerServiceWorker.ts └── typings │ └── math-expression-evaluator.d.ts ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | charset = utf-8 7 | 8 | [*.js] 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.lua] 18 | indent_style = tab 19 | insert_final_newline = false 20 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Eryn Lynn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RadialSpriteSheetGenerator 2 | 3 |

4 | Thinking 5 |

6 | 7 | This is a web app that generates radial progress indicator sprite sheets right in the browser. 8 | 9 | 10 | 11 | ## Building locally 12 | To build the web app locally and make changes yourself, simply clone this repository and run [`yarn`](https://yarnpkg.com/en/) to install the dependencies, then `yarn start` to start the app. 13 | 14 | ## Use in Roblox 15 | For ease of use in Roblox, a module is provided that can take in configuration the web app provides and can make an ImageLabel or ImageButton display your progress indicator with little work. 16 | 17 | To get prepped, follow these instructions: 18 | 19 | 1. To get the module, save it from [here](https://raw.githubusercontent.com/evaera/RadialSpriteSheetGenerator/master/roblox/RadialImage.lua) and then put it in your game as a ModuleScript. 20 | 1. The demo below assumes you put the module into ReplicatedStorage named "RadialImage". 21 | 2. Use the web app to generate your sprite sheets and configuration JSON. Upload the images to Roblox. 22 | 1. Make sure you tick the "Use in Roblox" checkbox before copying the JSON. 23 | 3. Modify the JSON, replacing the number part of `rbxassetid://1` with the Image ID of the first image you uploaded (and likewise for any additional images). 24 | 4. Store the JSON in your Roblox game, either as a StringValue or embedded in a script somewhere. 25 | 1. The demo below assumes you put the JSON into a StringValue named "Configuration" inside the script that's running. 26 | 27 | ```lua 28 | -- For the purposes of this demo, the script is located inside of an ImageLabel. 29 | -- Make sure you update the following line to point to wherever you added the module in your game: 30 | local RadialImage = require(game:GetService("ReplicatedStorage"):WaitForChild("RadialImage")) 31 | local r = RadialImage.new(script.Configuration.Value, script.Parent) 32 | 33 | game:GetService("RunService").Heartbeat:Connect(function() 34 | r:UpdateLabel(tick() % 5 / 5) 35 | end) 36 | ``` 37 | 38 | ### API 39 | 40 | ```cs 41 | RadialImage RadialImage.new ( configuration [, label]) 42 | /// Instantiates a new instances of RadialImage. 43 | // @param configuration Expects a valid configuration generated from the web app, in either JSON or table form. 44 | // @param label Optional, provides a label to be used with the UpdateLabel method. If this is omitted, you must pass it as the second argument to UpdateLabel. 45 | // @returns RadialImage A newly-created RadialImage instance. 46 | ``` 47 | 48 | ```cs 49 | void RadialImage:UpdateLabel (number alpha [, label]) 50 | /// Updates an ImageLabel or ImageButton to show the most appropriate frame based on the current progress. 51 | // @param alpha A number between 0 and 1 that indicates how far along the progress is. The label will then display the most appropriate image based on the number of available images in the sprite sheet. 52 | // @param label Optional, allows you to pass in a custom label to do the update operation on. Required if `label` is omitted from the instantiation. 53 | ``` 54 | 55 | ```cs 56 | RadialImage:GetFromAlpha (number alpha) 57 | /// Returns the information necessary to display the appropriate image from the sprite sheet. 58 | // @param alpha A number between 0 and 1 that indicates how far along the progress is. Information for the most appropriate frame will be returned based on the number of available images in the sprite sheet. 59 | // @returns Returns the X coordinate, Y coordinate, and the Image number for which to display based on the given alpha. 60 | ``` 61 | 62 | 63 | ```cs 64 | void RadialImage:Preload () 65 | /// Preloads the images in the given sheet and caches them in ImageLabels. You must call :Destroy() when you're done if you call :Preload() 66 | // NOTE: Make sure your script isn't inside the label because your label will be cloned. 67 | ``` 68 | 69 | ```cs 70 | void RadialImage:Destroy () 71 | /// Destroys the cache of preloaded image labels. You only need to call this if you called Preload. 72 | ``` -------------------------------------------------------------------------------- /images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' 2 | declare module '*.png' 3 | declare module '*.jpg' 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "src", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://eryn.io/RadialSpriteSheetGenerator", 6 | "dependencies": { 7 | "@material-ui/core": "^1.2.1", 8 | "gh-pages": "^1.2.0", 9 | "math-expression-evaluator": "^1.2.17", 10 | "react": "^16.4.0", 11 | "react-dom": "^16.4.2", 12 | "react-popper": "^0.10.4", 13 | "react-scripts-ts": "2.16.0" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts-ts start", 17 | "build": "react-scripts-ts build", 18 | "test": "react-scripts-ts test --env=jsdom", 19 | "predeploy": "npm run build", 20 | "deploy": "gh-pages -d build -m \"Automated deployment\"", 21 | "eject": "react-scripts-ts eject" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^23.0.2", 25 | "@types/material-ui": "^0.20.8", 26 | "@types/node": "^10.3.2", 27 | "@types/react": "^16.3.17", 28 | "@types/react-dom": "^16.0.6", 29 | "tslint-config-standard": "^7.0.0", 30 | "typescript": "^2.9.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Radial Sprite Sheet Generator 14 | 15 | 16 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Radial Generator", 3 | "name": "Radial Sprite Sheet Generator", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /roblox/RadialImage.lua: -------------------------------------------------------------------------------- 1 | -- This module displays a radial (circular) progress indicator based on images and config information 2 | -- that is generated at https://eryn.io/RadialSpriteSheetGenerator/ 3 | -- @readme https://github.com/evaera/RadialSpriteSheetGenerator/blob/master/README.md 4 | -- @author evaera 5 | 6 | local HttpService = game:GetService("HttpService") 7 | local ContentProvider = game:GetService("ContentProvider") 8 | 9 | local RadialImage = { _version = 1 } 10 | RadialImage.__index = RadialImage 11 | 12 | local ConfigurationProperties = { 13 | version = "number"; 14 | size = "number"; 15 | count = "number"; 16 | columns = "number"; 17 | rows = "number"; 18 | images = "table"; 19 | } 20 | 21 | function RadialImage.new(config, label) 22 | if type(config) == "string" then 23 | config = HttpService:JSONDecode(config) 24 | elseif type(config) ~= "table" then 25 | error("Argument #1 (configuration) must be a JSON string or table.", 2) 26 | end 27 | 28 | for k, v in pairs(config) do 29 | if ConfigurationProperties[k] == nil then 30 | error(("Invalid property name in Radial Image configuration: %s"):format(k), 2) 31 | end 32 | 33 | if type(v) ~= ConfigurationProperties[k] then 34 | error(("Invalid property type %q in Radial Image configuration: must be a %s."):format(k, ConfigurationProperties[k]), 2) 35 | end 36 | end 37 | 38 | if config.version ~= RadialImage._version then 39 | error(("Passed configuration version does not match this module's version (which is %d)"):format(RadialImage._version), 2) 40 | end 41 | 42 | local self = { config = config; label = label } 43 | setmetatable(self, RadialImage) 44 | 45 | return self 46 | end 47 | 48 | function RadialImage:Preload() 49 | if self.label == nil then 50 | error("You must provide a label to RadialImage.new to use Preload", 2) 51 | end 52 | 53 | self.labels = {} 54 | 55 | for _, image in ipairs(self.config.images) do 56 | local label = self.label:Clone() 57 | label.Image = image 58 | label.Visible = true 59 | label.Size = UDim2.new(0, 0, 0, 0) 60 | label.Parent = self.label.Parent 61 | table.insert(self.labels, label) 62 | end 63 | 64 | ContentProvider:PreloadAsync(self.labels) 65 | 66 | for _, label in ipairs(self.labels) do 67 | label.Visible = false 68 | end 69 | end 70 | 71 | function RadialImage:Destroy() 72 | for _, label in ipairs(self.labels) do 73 | label:Destroy() 74 | end 75 | 76 | self.labels = nil 77 | end 78 | 79 | function RadialImage:GetFromAlpha(alpha) 80 | if type(alpha) ~= "number" then 81 | error("Argument #1 (alpha) to GetFromAlpha must be a number.", 2) 82 | end 83 | 84 | local count, size, columns, rows = self.config.count, self.config.size, self.config.columns, self.config.rows 85 | local index = alpha >= 1 and count - 1 or math.floor(alpha * count) 86 | local page = math.floor(index / (columns * rows)) + 1 87 | local pageIndex = index - (columns * rows * (page - 1)) 88 | local x = (pageIndex % columns) * size 89 | local y = math.floor(pageIndex / columns) * size 90 | 91 | return x, y, page 92 | end 93 | 94 | function RadialImage:UpdateLabel(alpha, label) 95 | label = label or self.label 96 | 97 | if type(alpha) ~= "number" then 98 | error("Argument #1 (alpha) to UpdateLabel must be a number.", 2) 99 | end 100 | 101 | if typeof(label) ~= "Instance" or not (label:IsA("ImageLabel") or label:IsA("ImageButton")) then 102 | error("Attempt to update label but no label has been given. Either pass the label as argument #2 to \"new\", or as argument #2 to \"UpdateLabel\".", 2) 103 | end 104 | 105 | local x, y, page = self:GetFromAlpha(alpha) 106 | 107 | label.ImageRectSize = Vector2.new(self.config.size, self.config.size) 108 | label.ImageRectOffset = Vector2.new(x, y) 109 | label.Image = alpha <= 0 and "" or self.config.images[page] 110 | end 111 | 112 | return RadialImage -------------------------------------------------------------------------------- /src/components/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, CssBaseline, Toolbar, Typography } from '@material-ui/core' 2 | import * as React from 'react' 3 | import Generator from './Generator' 4 | 5 | // import logo from '../logo.svg' 6 | 7 | class App extends React.Component { 8 | public render () { 9 | return ( 10 | 11 | 12 |
13 | 14 | 15 | Radial Sprite Sheet Generator 16 | 17 | 18 |
19 | 20 |
21 | ) 22 | } 23 | } 24 | 25 | export default App 26 | -------------------------------------------------------------------------------- /src/components/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Options } from './SheetRenderer' 3 | 4 | export interface Slice { 5 | startAngle: number, 6 | endAngle: number 7 | } 8 | 9 | interface CanvasProps { 10 | image: HTMLImageElement, 11 | options: Options, 12 | slices: Slice[], 13 | size: number, 14 | savable?: boolean, 15 | index?: number, 16 | max?: number 17 | } 18 | 19 | interface CanvasState { 20 | dataURL: string 21 | } 22 | 23 | export default class Canvas extends React.Component { 24 | state: CanvasState 25 | 26 | constructor (props: CanvasProps) { 27 | super(props) 28 | 29 | this.state = { 30 | dataURL: '' 31 | } 32 | } 33 | 34 | componentDidMount () { 35 | this.updateCanvas() 36 | } 37 | 38 | componentDidUpdate (prevProps: CanvasProps) { 39 | if (prevProps.options !== this.props.options) { 40 | this.updateCanvas() 41 | } 42 | } 43 | 44 | updateCanvas () { 45 | const canvas = this.refs.canvas as HTMLCanvasElement 46 | const ctx = canvas.getContext('2d') as CanvasRenderingContext2D 47 | const size = this.props.size 48 | const radius = size / 2 49 | 50 | ctx.clearRect(0, 0, canvas.width, canvas.height) 51 | 52 | let x = 0 53 | let y = 0 54 | 55 | this.props.slices.forEach(slice => { 56 | const cx = x + radius 57 | const cy = y + radius 58 | 59 | ctx.save() 60 | 61 | if (slice.startAngle !== slice.endAngle) { 62 | ctx.beginPath() 63 | ctx.moveTo(cx, cy) 64 | ctx.arc(cx, cy, size, slice.startAngle * (Math.PI / 180), slice.endAngle * (Math.PI / 180)) 65 | ctx.lineTo(cx, cy) 66 | ctx.closePath() 67 | ctx.clip() 68 | } 69 | 70 | ctx.drawImage(this.props.image, x, y, size, size) 71 | ctx.restore() 72 | 73 | x += size 74 | 75 | if (x + size > this.props.options.width) { 76 | x = 0 77 | y += size 78 | } 79 | }) 80 | 81 | this.setState({ dataURL: canvas.toDataURL() }) 82 | } 83 | 84 | render () { 85 | const canvas = 86 | return ( 87 | 88 | {this.props.savable ? ( 89 | 90 | {canvas} 91 | 92 | ) : canvas} 93 | 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/Generator.css: -------------------------------------------------------------------------------- 1 | .Generator-form { 2 | max-width: 900px; 3 | margin: 50px auto 10px; 4 | } 5 | 6 | .Generator-options { 7 | display: flex; 8 | flex-wrap: wrap; 9 | } 10 | 11 | .Generator-options > div, .Generator-options > label { 12 | margin: 20px; 13 | } 14 | 15 | .Generator-image-container { 16 | width: 200px; 17 | text-align: center; 18 | float: left; 19 | } 20 | 21 | .Generator-image-container canvas { 22 | display: inline-block; 23 | max-width: 200px; 24 | } 25 | 26 | .Generator-drop { 27 | width: 200px; 28 | height: 200px; 29 | border: 4px dashed gray; 30 | border-radius: 25%; 31 | } 32 | 33 | .Generator-image-container-control { 34 | text-align: center; 35 | margin-top: 20px; 36 | } 37 | 38 | .Generator-output { 39 | text-align: center; 40 | } 41 | 42 | .Generator-output canvas { 43 | display: inline-block; 44 | margin: 20px; 45 | } 46 | 47 | form > p { 48 | /* width: 80%; */ 49 | margin: 0 auto 50px; 50 | line-height: 1.5em; 51 | } -------------------------------------------------------------------------------- /src/components/Generator.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Checkbox, FormControlLabel, TextField, Tooltip } from '@material-ui/core' 2 | import * as React from 'react' 3 | import { MAX_DECAL_SIZE } from '../constants' 4 | import './Generator.css' 5 | import SheetRenderer, { Options, PreviewOptions } from './SheetRenderer' 6 | 7 | interface GeneratorState { 8 | image?: string, 9 | loaded: boolean, 10 | options: Options, 11 | preview: PreviewOptions, 12 | } 13 | 14 | const parseNumber = (s: string) => { 15 | const n = parseFloat(s) 16 | if (isNaN(n)) { 17 | return undefined 18 | } 19 | 20 | return n 21 | } 22 | 23 | export default class Generator extends React.Component { 24 | state: GeneratorState 25 | 26 | constructor (props: {}) { 27 | super(props) 28 | 29 | this.state = { 30 | loaded: false, 31 | options: { 32 | width: 1024, 33 | height: 1024, 34 | angleFormula: '360 * a', 35 | startAtFormula: '0', 36 | name: 'untitled', 37 | roblox: false, 38 | frames: 60 39 | }, 40 | preview: { 41 | speed: 5 42 | } 43 | } 44 | } 45 | 46 | handleImageSelected = (e: any) => { 47 | const target = e.target 48 | this.setState((prevState: GeneratorState) => ({ 49 | image: target.files.length > 0 ? URL.createObjectURL(target.files[0]) : undefined, 50 | loaded: false, 51 | options: { 52 | ...prevState.options, 53 | name: target.files.length > 0 ? target.files[0].name : 'untitled' 54 | } 55 | })) 56 | } 57 | 58 | handleImageLoaded = (e: any) => { 59 | const target = e.target 60 | this.setState((prevState: GeneratorState) => ({ 61 | loaded: true, 62 | options: { 63 | ...prevState.options, 64 | size: target.naturalHeight 65 | } 66 | })) 67 | } 68 | 69 | handleChange = (key: string, isNumber = false, subTree = 'options') => (e: any) => { 70 | const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value 71 | this.setState((prevState: GeneratorState) => ({ 72 | [subTree]: { 73 | ...prevState.options, 74 | [key]: isNumber ? parseNumber(value) : value 75 | } 76 | })) 77 | } 78 | 79 | render () { 80 | return ( 81 |
82 | {this.state.image ? ( 83 | Image 90 | ) : '' } 91 |
92 |

This web app can generate a sprite sheet for radial/circular progress indicators, for use in platforms or game engines that do not support clipping masks (such as Roblox). Use the preview to ensure that your output will be smooth based on your intended display speed. Increase the amount of frames for increased fidelity, but at the cost of requiring more images.

See Supported Math Symbols for use in the "formula" options, in addition to "a" which is a number between 0 and 1, representing progress through the animation.

93 |
94 | {this.state.image && this.state.loaded ? ( 95 | 96 | ) : ( 97 |
98 | )} 99 |
100 | 104 |
105 |
106 | 107 |
108 | 109 | 112 | 113 | 114 | 115 | Math.min(this.state.options.width, this.state.options.height) : false}/> 118 | 119 | 120 | 121 | 124 | 125 | 126 | 127 | 131 | 132 | 133 | 134 | 137 | 138 | 139 | 140 | MAX_DECAL_SIZE : false)}/> 143 | 144 | 145 | 146 | MAX_DECAL_SIZE : false)}/> 149 | 150 | 151 | 153 | } label="Using in Roblox" /> 154 | 155 | {this.state.options.roblox && ( 156 |
157 |

For use in Roblox, please see the README for instructions.

158 | {this.state.options.width > MAX_DECAL_SIZE || this.state.options.height > MAX_DECAL_SIZE && ( 159 |

The maximum Roblox image upload size is {MAX_DECAL_SIZE}x{MAX_DECAL_SIZE}.

160 | )} 161 |
162 | )} 163 |
164 |
165 |
166 |
167 | {this.refs.image && this.state.loaded ? ( 168 | 169 | ) : ''} 170 |
171 |
172 | ) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/components/SheetRenderer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Canvas, { Slice } from './Canvas' 3 | import * as mexp from 'math-expression-evaluator' 4 | 5 | export interface Options { 6 | width: number, 7 | height: number, 8 | angleFormula: string, 9 | startAtFormula: string, 10 | name: string, 11 | roblox: boolean, 12 | frames: number 13 | size?: number, 14 | } 15 | 16 | export interface PreviewOptions { 17 | speed: number 18 | } 19 | 20 | export interface SheetRendererProps { 21 | image: HTMLImageElement, 22 | options: Options, 23 | preview?: PreviewOptions, 24 | showConfiguration?: boolean 25 | } 26 | 27 | const enum TokenType { 28 | Function = 0, 29 | BinaryFunction = 2, 30 | Constant = 3, 31 | AffixFunction = 7, 32 | Function2 = 8, 33 | Function3 = 12 34 | } 35 | 36 | interface SheetRendererState { 37 | frameIndex?: number 38 | } 39 | export default class SheetRenderer extends React.Component { 40 | state: SheetRendererState 41 | private dead?: boolean 42 | private _length: number 43 | 44 | constructor (props: SheetRendererProps) { 45 | super(props) 46 | 47 | this._length = -1 48 | this.state = {} 49 | } 50 | 51 | componentWillReceiveProps (props: SheetRendererProps) { 52 | if (props.preview) { 53 | this.setState({ frameIndex: 0 }) 54 | } 55 | } 56 | 57 | componentDidMount () { 58 | this.updatePreview() 59 | } 60 | 61 | componentWillUnmount () { 62 | this.dead = true 63 | } 64 | 65 | updatePreview = () => { 66 | if (!this.props.preview || this.dead) return 67 | const time = (Date.now() % this.animationSpeed) / this.animationSpeed 68 | const frameIndex = Math.floor(this.slices.length * time) 69 | this.setState({ frameIndex }) 70 | 71 | if (!this.dead) requestAnimationFrame(this.updatePreview) 72 | } 73 | 74 | get configurationString (): string { 75 | return JSON.stringify({ 76 | version: 1, 77 | size: this.imageSize, 78 | count: this.length, 79 | columns: Math.floor(this.props.options.width / this.imageSize), 80 | rows: Math.floor(this.props.options.height / this.imageSize), 81 | images: this.props.options.roblox ? Array.apply(null, { length: (Math.ceil(this.length / this.maxSlicesPerCanvas)) }) 82 | .map(Number.call, Number).map((n: number) => (this.props.options.roblox ? 'rbxassetid://' : 'path/to/image') + (n + 1)) : undefined 83 | }) 84 | } 85 | 86 | get length (): number { 87 | if (this._length === -1) { 88 | this._length = this.slices.length 89 | } 90 | 91 | return this._length 92 | } 93 | 94 | get animationSpeed (): number { 95 | if (!this.props.preview) return 0 96 | return (this.props.preview.speed || 1) * 1000 97 | } 98 | 99 | get imageSize (): number { 100 | return this.props.options.size || this.props.image.naturalHeight 101 | } 102 | 103 | get maxSlicesPerCanvas (): number { 104 | return Math.floor((this.props.options.width / this.imageSize)) * Math.floor((this.props.options.height / this.imageSize)) || 1 105 | } 106 | 107 | get angleFormula (): string { 108 | return this.props.options.angleFormula 109 | } 110 | 111 | get startAtFormula (): string { 112 | return this.props.options.startAtFormula 113 | } 114 | 115 | get frames (): number { 116 | return this.props.options.frames 117 | } 118 | 119 | get slices (): Slice[] { 120 | if (!this.frames) { 121 | this._length = 1 122 | 123 | return [{ 124 | startAngle: 0, 125 | endAngle: 360 126 | }] 127 | } 128 | 129 | const steps: number[] = [] 130 | const startAtAngles: number[] = [] 131 | const tokens: mexp.Token[] = [{ 132 | type: TokenType.Constant, 133 | show: 'a', 134 | token: 'a', 135 | value: 'a' 136 | }] 137 | for (let frame = 1; frame <= this.frames; frame++) { 138 | let stepResult = 0 139 | let startAtResult = 0 140 | 141 | try { 142 | const pair = { a: frame / this.frames } 143 | stepResult = mexp.eval(this.angleFormula, tokens, pair) 144 | startAtResult = mexp.eval(this.startAtFormula, tokens, pair) 145 | } catch (e) { 146 | // do nothing 147 | } 148 | 149 | if (!isNaN(stepResult)) { 150 | steps.push(stepResult) 151 | } 152 | 153 | if (!isNaN(startAtResult)) { 154 | startAtAngles.push(startAtResult - 90) 155 | } else { 156 | startAtAngles.push(-90) 157 | } 158 | } 159 | 160 | // const lastStep = steps[steps.length - 1] 161 | // if (lastStep !== 360 && lastStep !== -360) { 162 | // steps.push(this.increment < 0 ? -360 : 360) 163 | // } 164 | 165 | this._length = steps.length 166 | 167 | // const startAt = (this.props.options.startAt !== undefined ? this.props.options.startAt : 0) + -90 168 | return steps.map((n, i): Slice => ({ 169 | startAngle: startAtAngles[i] + (n <= 0 ? n : 0), 170 | endAngle: startAtAngles[i] + (n >= 0 ? n : 0) 171 | })) 172 | } 173 | 174 | render () { 175 | const pages: Slice[][] = [] 176 | let slices = this.slices 177 | 178 | if (this.state.frameIndex !== undefined) { 179 | slices = [slices[this.state.frameIndex]] 180 | } 181 | 182 | while (slices.length > 0) { 183 | pages.push(slices.splice(0, this.maxSlicesPerCanvas)) 184 | } 185 | 186 | let options = this.props.options 187 | 188 | if (this.props.preview) { 189 | options = { 190 | ...options, 191 | width: this.imageSize, 192 | height: this.imageSize 193 | } 194 | } 195 | 196 | return ( 197 | 198 | {this.props.showConfiguration && ( 199 |