OverviewFilesCommitsIssuesDocsReleasesPeers

hypergodot commits / 8fd2476

Progress on custom levels and level discovery
8fd24760203030352c4373440985cd7ffd23d0c3
by mauve <ranger@mauve.moe> · 2021-11-02T16:27:23.000Z

Changed files (14) +596 -8

README.md +1 -0
……@@ -31,6 +31,7 @@ A Godot plugin and example project for loading assets from the [hypercore-protoc
3131 - [ ] Create custom maps from an image and a JSON file
3232 - [ ] Auto-discover which maps are active
3333- [ ] Enable code to work in Godot Web in Agregore
34 - [ ] Use new [JavaScriptObject](https://godotengine.org/article/godot-web-progress-report-9) API from Godot 4
3435- [ ] Enable code to work in Godot Web in regular browsers
3536- [ ] Load gltf scene from hyper
3637- [ ] Load VRM avatar from hyper (social multiplayer) [VRM stuff](https://github.com/V-Sekai/godot-vrm/blob/godot3/addons/vrm/import_vrm.gd)
src/MultiplayerDemo/FlyBalls.gd +23 -7
……@@ -3,8 +3,8 @@ extends Node2D
33var RemotePlayer = preload("res://src/MultiplayerDemo/RemotePlayer.tscn")
44var RemoteBullet = preload("res://src/MultiplayerDemo/RemoteBullet.tscn")
55
6#export var url: String = "hyper://blog.mauve.moe/"
7export var url: String = "hyper://70338a94d7415990ab4a3160cf98a5d940243d7fa24dd297352e66b52d3ff841/"
6export var url: String = "hyper://blog.mauve.moe/"
7#export var url: String = "hyper://70338a94d7415990ab4a3160cf98a5d940243d7fa24dd297352e66b52d3ff841/"
88export var profile : String = ""
99export var spawn_gateway = true
1010
……@@ -19,7 +19,16 @@ const knownPlayers = {}
1919
2020func _ready():
2121 if spawn_gateway: gateway.start()
22 elif profile.length() != 0: player.load_profile(profile)
22 else: _perform_setup()
23
24func _perform_setup():
25 if profile.length() != 0: player.load_profile(profile)
26
27 gossip.url = url
28 gossip.start_listening()
29
30 $LevelInfo.url = url
31 $LevelInfo.load_info()
2332
2433func get_player_object(id):
2534 if knownPlayers.has(id): return knownPlayers[id]
……@@ -32,6 +41,13 @@ func get_player_object(id):
3241
3342 return remotePlayer
3443
44func set_background(image_path):
45 var texture = ImageTexture.new()
46 var image = Image.new()
47 image.load(image_path)
48 texture.create_from_image(image)
49 $Backround.texture = texture
50
3551func _broadcast_player():
3652 var position = player.position
3753 var rotation = player.rotation_degrees
……@@ -104,10 +120,7 @@ func _on_Player_shoot(Bullet, direction, location):
104120 _broadcast_shoot(direction, location)
105121
106122func _on_HyperGateway_started_gateway(_pid):
107 if profile.length() != 0: player.load_profile(profile)
108
109 gossip.url = url
110 gossip.start_listening()
123 _perform_setup()
111124
112125func _on_HyperGossip_listening(_extension_name):
113126 print('Started listening on events')
……@@ -124,3 +137,6 @@ func _on_HyperGossip_event(type, data, from):
124137func _on_Player_moved(_position, _rotation, _velocity):
125138 # TODO: Don't broadcast too often to avoid flooding the system
126139 _broadcast_player()
140
141func _on_LevelInfo_image(path):
142 set_background(path)
src/MultiplayerDemo/FlyBalls.tscn +16 -1
……@@ -1,14 +1,25 @@
1[gd_scene load_steps=6 format=2]
1[gd_scene load_steps=8 format=2]
22
33[ext_resource path="res://src/MultiplayerDemo/PlayerSprite.tscn" type="PackedScene" id=1]
44[ext_resource path="res://src/MultiplayerDemo/Player.gd" type="Script" id=2]
55[ext_resource path="res://src/MultiplayerDemo/FlyBalls.gd" type="Script" id=3]
66[ext_resource path="res://src/HyperGateway/HyperGateway.gd" type="Script" id=4]
77[ext_resource path="res://src/multiplayer/HyperGossip.gd" type="Script" id=5]
8[ext_resource path="res://src/MultiplayerDemo/LevelInfo.tscn" type="PackedScene" id=6]
9[ext_resource path="res://src/MultiplayerDemo/background.png" type="Texture" id=7]
810
911[node name="FlyBalls" type="Node2D"]
1012script = ExtResource( 3 )
1113
14[node name="Backround" type="TextureRect" parent="."]
15margin_right = 665.0
16margin_bottom = 665.0
17texture = ExtResource( 7 )
18stretch_mode = 2
19__meta__ = {
20"_edit_use_anchors_": false
21}
22
1223[node name="Player" parent="." instance=ExtResource( 1 )]
1324position = Vector2( 333, 333 )
1425script = ExtResource( 2 )
……@@ -21,8 +32,12 @@ extension_name = "flyballs-v1"
2132script = ExtResource( 4 )
2233autoStart = false
2334
35[node name="LevelInfo" parent="." instance=ExtResource( 6 )]
36autoload_image = true
37
2438[connection signal="moved" from="Player" to="." method="_on_Player_moved"]
2539[connection signal="shoot" from="Player" to="." method="_on_Player_shoot"]
2640[connection signal="event" from="HyperGossip" to="." method="_on_HyperGossip_event"]
2741[connection signal="listening" from="HyperGossip" to="." method="_on_HyperGossip_listening"]
2842[connection signal="started_gateway" from="HyperGateway" to="." method="_on_HyperGateway_started_gateway"]
43[connection signal="image" from="LevelInfo" to="." method="_on_LevelInfo_image"]
src/MultiplayerDemo/LevelInfo.gd +143 -0
……@@ -0,0 +1,143 @@
1extends Node
2
3var URLParser = preload("res://src/utils/URLParser.gd")
4var parser = URLParser.new()
5
6signal info(title, image)
7signal no_info()
8signal image(path)
9signal updated()
10signal resolved(url)
11signal error(err)
12
13export var url = 'hyper://multiplayer-level/'
14export var cache_folder: String = 'user://hyper-file-cache/'
15export var level_file: String = 'level.json'
16
17export var autoload = false
18export var autoload_image = false
19
20const DEFAULT_TITLE = 'New Level'
21
22var info_cache = null
23var has_loaded = false
24var picture_path = ''
25
26func _ready():
27 var dir = Directory.new()
28 var err = dir.make_dir_recursive(cache_folder)
29 if err != OK:
30 printerr("Unable to create cache directory ", err)
31
32 print("Level info setup")
33 if autoload:
34 load_info()
35
36func get_title():
37 if info_cache == null: return DEFAULT_TITLE
38 if !info_cache.has('title'): return DEFAULT_TITLE
39 return info_cache.title
40
41func set_title(title):
42 update_info({
43 "title": title
44 })
45
46func update_info(updates):
47 var data = {}
48
49 if info_cache != null:
50 for key in info_cache.keys():
51 data[key] = info_cache[key]
52
53 for key in updates.keys():
54 data[key] = info_cache[key]
55
56 var json = JSON.print(data)
57 var info_url = url + level_file
58
59 $updateInfoRequest.request(info_url, [], true, HTTPClient.METHOD_PUT, json)
60
61func load_info():
62 var info_url = url + level_file
63 $loadInfoRequest.request(info_url)
64
65func resolve_url():
66 var url_record = url + '.well-known/hyper'
67 $resolveURLRequest.request(url_record)
68
69func download_image(url: String):
70 print('Loading image: ', url)
71 var file_name = url.get_file()
72 var key = parser.parse(url).host
73 # Save the picture with the profile key prefixed to it
74 picture_path = cache_folder + key + '-' + file_name
75 $loadImageRequest.download_file = picture_path
76 $loadImageRequest.request(url)
77
78func upload_image(path: String):
79 var picture_name = path.get_file()
80 var picture_url = url + picture_name
81
82 $uploader.upload_file(picture_url, path)
83
84func _on_loadInfoRequest_request_completed(_result, response_code, _headers, body):
85 print("Loaded info", _result, " ", response_code)
86 if response_code != 200:
87 printerr('Cannot load existing level info: ', response_code)
88 emit_signal("no_info")
89 return
90
91 var parsed = JSON.parse(body.get_string_from_utf8())
92 if parsed.error != OK:
93 printerr(parsed.error_string)
94 emit_signal("no_info")
95 return
96
97 info_cache = parsed.result
98
99 var title = info_cache.title
100 var image = info_cache.image
101
102 emit_signal("info", title, image)
103
104 if image != null and autoload_image:
105 download_image(image)
106
107func _on_uploader_uploaded(resolved_url, response_code):
108 if response_code != 200:
109 printerr('Unable to upload file ')
110 return
111
112 if !url || (url.length() == 0):
113 printerr("Unable to load Link header for image.")
114 return
115
116 update_info({
117 "image": resolved_url
118 })
119
120 download_image(resolved_url)
121
122func _on_loadImageRequest_request_completed(result, response_code, _headers, body):
123 if result != OK or response_code != 200:
124 printerr('Unable to load image: ', result, ": ", body.get_string_from_utf8())
125 return
126
127 print(result, ' Image got downloaded: ', picture_path)
128
129 emit_signal('image', picture_path)
130
131func _on_updateInfoRequest_request_completed(_result, _response_code, _headers, _body):
132 emit_signal("updated")
133
134func _on_resolveURLRequest_request_completed(result, response_code, _headers, body: PoolByteArray):
135 if result != OK or response_code != 200:
136 printerr('Unable to load final profile URL')
137 return
138
139 var lines = body.get_string_from_utf8()
140 var url = lines.split('\n')[0]
141 if !url.ends_with('/'):
142 url += '/'
143 emit_signal('resolved', url)
src/MultiplayerDemo/LevelInfo.tscn +29 -0
……@@ -0,0 +1,29 @@
1[gd_scene load_steps=4 format=2]
2
3[ext_resource path="res://src/MultiplayerDemo/LevelInfo.gd" type="Script" id=1]
4[ext_resource path="res://src/HyperRequest/HyperRequest.gd" type="Script" id=2]
5[ext_resource path="res://src/HyperRequest/HyperUploader.gd" type="Script" id=3]
6
7[node name="LevelInfo" type="Node"]
8script = ExtResource( 1 )
9
10[node name="loadInfoRequest" type="HTTPRequest" parent="."]
11script = ExtResource( 2 )
12
13[node name="loadImageRequest" type="HTTPRequest" parent="."]
14script = ExtResource( 2 )
15
16[node name="updateInfoRequest" type="HTTPRequest" parent="."]
17script = ExtResource( 2 )
18
19[node name="resolveURLRequest" type="HTTPRequest" parent="."]
20script = ExtResource( 2 )
21
22[node name="uploader" type="Node" parent="."]
23script = ExtResource( 3 )
24
25[connection signal="request_completed" from="loadInfoRequest" to="." method="_on_loadInfoRequest_request_completed"]
26[connection signal="request_completed" from="loadImageRequest" to="." method="_on_loadImageRequest_request_completed"]
27[connection signal="request_completed" from="updateInfoRequest" to="." method="_on_updateInfoRequest_request_completed"]
28[connection signal="request_completed" from="resolveURLRequest" to="." method="_on_resolveURLRequest_request_completed"]
29[connection signal="uploaded" from="uploader" to="." method="_on_uploader_uploaded"]
src/MultiplayerDemo/LevelSelect.gd +18 -0
……@@ -0,0 +1,18 @@
1extends VBoxContainer
2
3# Use gossip to discover levels (including your own?)
4# Button to open up LoadLevel to create your own level to enter
5
6# Declare member variables here. Examples:
7# var a = 2
8# var b = "text"
9
10
11# Called when the node enters the scene tree for the first time.
12func _ready():
13 pass # Replace with function body.
14
15
16# Called every frame. 'delta' is the elapsed time since the previous frame.
17#func _process(delta):
18# pass
src/MultiplayerDemo/LevelSelect.tscn +11 -0
……@@ -0,0 +1,11 @@
1[gd_scene load_steps=2 format=2]
2
3[ext_resource path="res://src/MultiplayerDemo/LevelSelect.gd" type="Script" id=1]
4
5[node name="Control" type="VBoxContainer"]
6anchor_right = 1.0
7anchor_bottom = 1.0
8script = ExtResource( 1 )
9__meta__ = {
10"_edit_use_anchors_": false
11}
src/MultiplayerDemo/LoadLevel.gd +87 -0
……@@ -0,0 +1,87 @@
1extends "res://src/ResizeRoot.gd"
2
3# TODO: Better error dialogs
4
5signal profile_ready(url)
6
7export var level_url = 'hyper://flyballs-level/'
8export var spawn_gateway = true
9
10func _ready():
11 if spawn_gateway: $HyperGateway.start()
12
13func load_level():
14 print("Initial level load")
15 $LevelInfo.url = level_url
16 $LevelInfo.load_info()
17
18func hide_loader():
19 $Loader.hide()
20 $Form.show()
21
22func show_loader():
23 $Loader.show()
24 $Form.hide()
25
26func get_title():
27 return $Form/TitleInput.text
28
29func load_image(image: String):
30 print('Loading image')
31 show_loader()
32 $LevelInfo.download_image(image)
33
34func _on_ImageChooser_pressed():
35 print('Choosing image')
36 $FileDialog.popup()
37
38func _on_FileDialog_file_selected(path: String):
39 print('Selected image, uploading:', path)
40 show_loader()
41 $LevelInfo.upload_image(path)
42
43func _on_HyperGateway_started_gateway(_pid):
44 load_level()
45
46func _on_StartButton_pressed():
47 print("Starting next step")
48 show_loader()
49 $LevelInfo.resolve_url()
50
51func _on_LevelInfo_error(err):
52 # TODO: Show an error dialog?
53 print("Error interacting with profile:", err)
54
55func _on_LevelInfo_image(path):
56 print("Image loaded, setting texture")
57 hide_loader()
58 var texture = ImageTexture.new()
59 var image = Image.new()
60 image.load(path)
61 texture.create_from_image(image)
62 texture.set_size_override(Vector2(64, 64))
63
64 $Form/ImageChooser.icon = texture
65
66func _on_LevelInfo_info(title: String, image: String):
67 print("Loaded level info:", {"title": title, "image": image})
68 $Form/StartButton.disabled = false
69 $Form/TitleInput.text = title
70
71 if image != null && image.length() != 0:
72 load_image(image)
73 else: hide_loader()
74
75func _on_LevelInfo_updated():
76 print('Updated level info"')
77 hide_loader()
78 $Form/StartButton.disabled = false
79
80func _on_LevelInfo_resolved(url):
81 print('Profile ready')
82 print(url)
83 emit_signal('profile_ready', url)
84
85func _on_LevelInfo_no_info():
86 print('No existing profile info')
87 hide_loader()
src/MultiplayerDemo/LoadLevel.tscn +93 -0
……@@ -0,0 +1,93 @@
1[gd_scene load_steps=4 format=2]
2
3[ext_resource path="res://src/MultiplayerDemo/LoadLevel.gd" type="Script" id=1]
4[ext_resource path="res://src/HyperGateway/HyperGateway.gd" type="Script" id=2]
5[ext_resource path="res://src/MultiplayerDemo/LevelInfo.tscn" type="PackedScene" id=3]
6
7[node name="LoadLevel" type="VBoxContainer"]
8anchor_right = 1.0
9anchor_bottom = 1.0
10script = ExtResource( 1 )
11__meta__ = {
12"_edit_use_anchors_": false
13}
14
15[node name="Loader" type="CenterContainer" parent="."]
16margin_right = 666.0
17margin_bottom = 666.0
18size_flags_horizontal = 3
19size_flags_vertical = 3
20
21[node name="Label" type="Label" parent="Loader"]
22margin_left = 308.0
23margin_top = 326.0
24margin_right = 357.0
25margin_bottom = 340.0
26text = "Loading"
27
28[node name="Form" type="VBoxContainer" parent="."]
29visible = false
30margin_top = 335.0
31margin_right = 666.0
32margin_bottom = 666.0
33size_flags_vertical = 3
34
35[node name="TitleLabel" type="Label" parent="Form"]
36margin_right = 666.0
37margin_bottom = 14.0
38text = "Level Title"
39
40[node name="TitleInput" type="LineEdit" parent="Form"]
41margin_top = 18.0
42margin_right = 666.0
43margin_bottom = 42.0
44text = "New Level"
45
46[node name="PictureLabel" type="Label" parent="Form"]
47margin_top = 46.0
48margin_right = 666.0
49margin_bottom = 60.0
50text = "Picture"
51
52[node name="ImageChooser" type="Button" parent="Form"]
53margin_top = 64.0
54margin_right = 666.0
55margin_bottom = 84.0
56text = "Choose Image"
57
58[node name="StartButton" type="Button" parent="Form"]
59margin_top = 88.0
60margin_right = 666.0
61margin_bottom = 108.0
62disabled = true
63text = "Start"
64
65[node name="HyperGateway" type="Node" parent="."]
66script = ExtResource( 2 )
67autoStart = false
68
69[node name="FileDialog" type="FileDialog" parent="."]
70margin_top = 536.0
71margin_right = 666.0
72margin_bottom = 666.0
73popup_exclusive = true
74window_title = "Open a File"
75resizable = true
76dialog_autowrap = true
77mode = 0
78access = 2
79current_dir = "/home/mauve/programming/godot/hypergodot"
80current_path = "/home/mauve/programming/godot/hypergodot/"
81
82[node name="LevelInfo" parent="." instance=ExtResource( 3 )]
83
84[connection signal="pressed" from="Form/ImageChooser" to="." method="_on_ImageChooser_pressed"]
85[connection signal="pressed" from="Form/StartButton" to="." method="_on_StartButton_pressed"]
86[connection signal="started_gateway" from="HyperGateway" to="." method="_on_HyperGateway_started_gateway"]
87[connection signal="file_selected" from="FileDialog" to="." method="_on_FileDialog_file_selected"]
88[connection signal="error" from="LevelInfo" to="." method="_on_LevelInfo_error"]
89[connection signal="image" from="LevelInfo" to="." method="_on_LevelInfo_image"]
90[connection signal="info" from="LevelInfo" to="." method="_on_LevelInfo_info"]
91[connection signal="no_info" from="LevelInfo" to="." method="_on_LevelInfo_no_info"]
92[connection signal="resolved" from="LevelInfo" to="." method="_on_LevelInfo_resolved"]
93[connection signal="updated" from="LevelInfo" to="." method="_on_LevelInfo_updated"]
src/MultiplayerDemo/background.png +0 -0
(no content changes — binary or rename)
src/MultiplayerDemo/background.png.import +34 -0
……@@ -0,0 +1,34 @@
1[remap]
2
3importer="texture"
4type="StreamTexture"
5path="res://.import/background.png-4cff8a31e972161bb385b3e416e2e00b.stex"
6metadata={
7"vram_texture": false
8}
9
10[deps]
11
12source_file="res://src/MultiplayerDemo/background.png"
13dest_files=[ "res://.import/background.png-4cff8a31e972161bb385b3e416e2e00b.stex" ]
14
15[params]
16
17compress/mode=0
18compress/lossy_quality=0.7
19compress/hdr_mode=0
20compress/bptc_ldr=0
21compress/normal_map=0
22flags/repeat=0
23flags/filter=true
24flags/mipmaps=false
25flags/anisotropic=false
26flags/srgb=2
27process/fix_alpha_border=true
28process/premult_alpha=false
29process/HDR_as_SRGB=false
30process/invert_color=false
31stream=false
32size_limit=0
33detect_3d=true
34svg/scale=1.0
src/MultiplayerDemo/background.svg +39 -0
……@@ -0,0 +1,39 @@
1
2<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewbox="0 0 69 69" width="69" height="69">
3 <style>
4 .dot {
5 fill: rgb(165, 24, 201);
6 stroke: #FCFCFC;
7 stroke-width: 1;
8 }
9 .bg {
10 fill: #111;
11 }
12 </style>
13 <rect class="bg" width="69" height="69" />
14<g transform="rotate(-90, 34.5 34.5) translate(34.5, 34.5)">
15 <!--<circle class="dot" r="5.75" cx="11.5" cy="0" />
16<circle class="dot" r="5.75" cx="5.750000000000001" cy="9.959292143521044" />
17<circle class="dot" r="5.75" cx="-5.749999999999997" cy="9.959292143521045" />
18<circle class="dot" r="5.75" cx="-11.5" cy="1.4083438190194562e-15" />
19<circle class="dot" r="5.75" cx="-5.750000000000005" cy="-9.959292143521042" />
20<circle class="dot" r="5.75" cx="5.750000000000001" cy="-9.959292143521044" />-->
21
22 <!--<circle class="dot" r="5.75" cx="19.91858428704209" cy="11.499999999999998" />
23<circle class="dot" r="5.75" cx="1.4083438190194562e-15" cy="23" />
24<circle class="dot" r="5.75" cx="-19.91858428704209" cy="11.499999999999998" />
25<circle class="dot" r="5.75" cx="-19.918584287042087" cy="-11.500000000000002" />
26<circle class="dot" r="5.75" cx="-4.225031457058368e-15" cy="-23" />
27<circle class="dot" r="5.75" cx="19.918584287042084" cy="-11.50000000000001" />-->
28
29 <circle class="dot" r="5.75" cx="25.875" cy="0" />
30<circle class="dot" r="5.75" cx="12.937500000000004" cy="22.408407322922347" />
31<circle class="dot" r="5.75" cx="-12.937499999999995" cy="22.40840732292235" />
32<circle class="dot" r="5.75" cx="-25.875" cy="3.1687735927937763e-15" />
33<circle class="dot" r="5.75" cx="-12.93750000000001" cy="-22.408407322922347" />
34<circle class="dot" r="5.75" cx="12.937500000000004" cy="-22.408407322922347" />
35
36 <circle class="dot" r="5.75"/>
37</g>
38</svg>
39
src/MultiplayerDemo/background.svg.import +34 -0
……@@ -0,0 +1,34 @@
1[remap]
2
3importer="texture"
4type="StreamTexture"
5path="res://.import/background.svg-6e4643371d3cbed7c38493d11a0b6df3.stex"
6metadata={
7"vram_texture": false
8}
9
10[deps]
11
12source_file="res://src/MultiplayerDemo/background.svg"
13dest_files=[ "res://.import/background.svg-6e4643371d3cbed7c38493d11a0b6df3.stex" ]
14
15[params]
16
17compress/mode=0
18compress/lossy_quality=0.7
19compress/hdr_mode=0
20compress/bptc_ldr=0
21compress/normal_map=0
22flags/repeat=0
23flags/filter=true
24flags/mipmaps=false
25flags/anisotropic=false
26flags/srgb=2
27process/fix_alpha_border=true
28process/premult_alpha=false
29process/HDR_as_SRGB=false
30process/invert_color=false
31stream=false
32size_limit=0
33detect_3d=true
34svg/scale=1.0
src/MultiplayerDemo/generate_background.js +68 -0
……@@ -0,0 +1,68 @@
1const BLACK = '#111'
2const WHITE = '#FCFCFC'
3const PURPLE = 'rgb(165, 24, 201)'
4const PINK = '#E62EA5'
5
6const WIDTH = 69
7const HEIGHT = WIDTH
8
9const OUTLINE_WIDTH = 1
10
11const DOT_SIZE = WIDTH / 12
12
13console.log(`
14<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewbox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}">
15 <style>
16 .dot {
17 fill: ${PURPLE};
18 stroke: ${WHITE};
19 stroke-width: ${OUTLINE_WIDTH};
20 }
21 .bg {
22 fill: ${BLACK};
23 }
24 </style>
25 <rect class="bg" width="${WIDTH}" height="${HEIGHT}" />
26<g transform="rotate(-90, ${WIDTH/2} ${HEIGHT/2}) translate(${WIDTH/2}, ${HEIGHT/2})">
27 <!--${makeDots(6, WIDTH / 6)}-->
28
29 <!--${makeDots(6, (WIDTH / 6) * 2, 30)}-->
30
31 ${makeDots(6, (WIDTH / 6) * 2 + DOT_SIZE / 2)}
32
33 <circle class="dot" r="${DOT_SIZE}"/>
34</g>
35</svg>
36`)
37
38function makeDots (n, scale, offset = 0, size = DOT_SIZE) {
39 return makeCorners(n, offset).map((theta) => {
40 return `<circle class="dot" r="${size}" ${centerPoint(theta, scale)} />`
41 }).join('\n')
42}
43
44function makeCorners (n, offset = 0) {
45 const increment = 360 / n
46 const corners = []
47 for (let i = 0; i < n; i++) {
48 corners.push(i * increment + offset)
49 }
50
51 return corners
52}
53
54function centerPoint (theta, scale = 1) {
55 return `cx="${xAt(theta) * scale}" cy="${yAt(theta) * scale}"`
56}
57
58function xAt (theta) {
59 return Math.cos(toRad(theta))
60}
61
62function yAt (theta) {
63 return Math.sin(toRad(theta))
64}
65
66function toRad (theta) {
67 return Math.PI / 180 * theta
68}