├── .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 |
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 |
90 | ) : '' }
91 |
165 |
166 |