janusweb 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy.yml +71 -0
- package/Dockerfile +14 -0
- package/README.md +210 -7
- package/janusxr.com/.args +2 -0
- package/janusxr.com/.init.lua +30 -0
- package/media/assets/webui/apps/comms/voip.js +27 -25
- package/media/assets/webui/apps/editor/editor.js +3 -0
- package/media/assets/webui/apps/navigation/navigation.js +1 -1
- package/media/assets/webui/apps/xrfragment/level0-sidecarfile.js +308 -0
- package/media/assets/webui/apps/xrfragment/level2-hyperlink.js +265 -0
- package/media/assets/webui/apps/xrfragment/level7-engine-prefixes.export.js +93 -0
- package/media/assets/webui/apps/xrfragment/level7-engine-prefixes.import.js +229 -0
- package/media/assets/webui/apps/xrfragment/patch/metaquest-fix-flickering.js +15 -0
- package/media/assets/webui/apps/xrfragment/xrfragment.json +14 -0
- package/media/assets/webui/default.json +1 -0
- package/media/images/portal.svg +130 -0
- package/media/index.html +48 -1
- package/package.json +1 -1
- package/scripts/client.js +24 -0
- package/scripts/config.js +12 -1
- package/scripts/janusbase.js +1 -0
- package/scripts/janusparagraph.js +169 -22
- package/scripts/janusxrplayer.js +4 -6
- package/scripts/room.js +59 -36
- package/scripts/translators/paragraph/html-xml-rss.js +47 -0
- package/scripts/translators/peertube.js +89 -0
- package/scripts/translators/xrfragments.js +5 -5
- package/tests/room/paragraph.xml +47 -0
- package/utils/build.sh +1 -0
- package/utils/dev-link.sh +75 -0
- package/utils/release.binary.sh +36 -0
- package/utils/release.docker.sh +11 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/* XR URI Fragments spec (https://xrfragment.org)
|
|
2
|
+
*
|
|
3
|
+
* level0 sidecar files as per the XR URI Fragments spec
|
|
4
|
+
* https://xrfragment.org/#sidecar%20files
|
|
5
|
+
*
|
|
6
|
+
* NOTE: you can also load sidecarfiles in a roomscript manually:
|
|
7
|
+
*
|
|
8
|
+
* elation.events.add(null, 'room_load_complete', function(e){
|
|
9
|
+
* if( room?.sidecarfile ) room.sidecarfile.load()
|
|
10
|
+
* })
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
elation.require([], function() {
|
|
14
|
+
|
|
15
|
+
elation.extend('janusweb.sidecarfile', class {
|
|
16
|
+
constructor(object) {
|
|
17
|
+
this.scene = elation.engine.instances.default.systems.world.scene['world-3d']
|
|
18
|
+
this._object = object
|
|
19
|
+
this.extension = /\.(gltf|glb|dae)$/
|
|
20
|
+
this.extensionXRF = /\.xrf\./
|
|
21
|
+
if( room.url.match(this.extension) && this.isXRF(this.hideSystemFolder) ){
|
|
22
|
+
this.load()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
load(){
|
|
27
|
+
this.cleanup()
|
|
28
|
+
this.loadWebVTT() // https://xrfragment.org/#sidecar%20files
|
|
29
|
+
this.loadSound() // https://xrfragment.org/#sidecar%20files
|
|
30
|
+
this.initStartButton()
|
|
31
|
+
this.initSubtitle()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hideSystemFolder(o){
|
|
35
|
+
// https://xrfragment.org/#system%20folders
|
|
36
|
+
if(o.name[0] == '_'){
|
|
37
|
+
o.visible = false
|
|
38
|
+
o.pickable = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
isXRF(cb){
|
|
43
|
+
// for XRF heuristics see https://xrfragment.org/#%F0%9F%93%9C%20level0%3A%20File
|
|
44
|
+
let heuristic = room.url.match(this.extensionXRF)
|
|
45
|
+
this.scene.traverse( (o) => {
|
|
46
|
+
if( o.userData?.href ) heuristic = true // has XRF hyperlink
|
|
47
|
+
if( o.name[0] == '_' ) heuristic = true // has XRF system folder
|
|
48
|
+
})
|
|
49
|
+
return heuristic
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
loadSound(){ // https://xrfragment.org/#sidecar%20files
|
|
53
|
+
room.removeObject('xrf_audio')
|
|
54
|
+
const audio = room.url.replace(this.extension,'.ogg')
|
|
55
|
+
room.loadNewAsset("sound", {id:"xrf_audio", src:audio,proxy:false})
|
|
56
|
+
this.sound = room.createObject('sound',{ id: "xrf_audio", js_id: 'xrf_audio', loop:true })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
loadWebVTT(){ // https://xrfragment.org/#sidecar%20files
|
|
60
|
+
const webvtt = room.url.replace(this.extension,'.vtt')
|
|
61
|
+
fetch( webvtt )
|
|
62
|
+
.then( (res) => res.text() )
|
|
63
|
+
.then( (webvtt) => {
|
|
64
|
+
this.webvtt = this.parseWEBVTT(webvtt)
|
|
65
|
+
})
|
|
66
|
+
.catch( () => false ) // no biggy (optional)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
initStartButton(){
|
|
70
|
+
this.btn = room.createObject('object',{
|
|
71
|
+
id: 'cube',
|
|
72
|
+
js_id: 'btnstart',
|
|
73
|
+
pos: player.localToWorld( V(0,1.8,-1) ),
|
|
74
|
+
scale: '0.5 0.125 0.01',
|
|
75
|
+
col: '0.5 0.5 0.5',
|
|
76
|
+
lighting:false,
|
|
77
|
+
sync: true,
|
|
78
|
+
billboard: 'y',
|
|
79
|
+
collision_id: 'cube'
|
|
80
|
+
})
|
|
81
|
+
const label = this.btn.createObject('text',{
|
|
82
|
+
text: 'Start experience',
|
|
83
|
+
lighting:false,
|
|
84
|
+
col: '0 0 0',
|
|
85
|
+
pos: '0 -0.15 0.5',
|
|
86
|
+
scale: '1.2 5 1.2',
|
|
87
|
+
})
|
|
88
|
+
this.btn.addEventListener('click', () => this.start() )
|
|
89
|
+
this.positionStartButton()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
positionStartButton(){
|
|
93
|
+
setTimeout( () => {
|
|
94
|
+
this.btn.pos = player.localToWorld( V(0,1.8,-1) )
|
|
95
|
+
}, 500)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
initSubtitle(){
|
|
99
|
+
this.subtitle = player.createObject('paragraph',{
|
|
100
|
+
js_id: 'subtitle',
|
|
101
|
+
pos: '0 1.7 -1.5',
|
|
102
|
+
lighting: false,
|
|
103
|
+
pickable: false,
|
|
104
|
+
css: `.paragraphcontainer{
|
|
105
|
+
background: transparent;
|
|
106
|
+
text-align:center;
|
|
107
|
+
height:100%;
|
|
108
|
+
width:100%;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
font-size:55px;
|
|
112
|
+
line-height:55px;
|
|
113
|
+
text-shadow: 0px 0px 2px #FFFF;
|
|
114
|
+
color:black;
|
|
115
|
+
}
|
|
116
|
+
.subtitle{
|
|
117
|
+
width:100%;
|
|
118
|
+
}
|
|
119
|
+
.loading{
|
|
120
|
+
background:#FFF;
|
|
121
|
+
display:inline-block;
|
|
122
|
+
font-size:22px;
|
|
123
|
+
font-family: monospace;
|
|
124
|
+
line-height:30px;
|
|
125
|
+
padding:20px;
|
|
126
|
+
border-radius:15px;
|
|
127
|
+
}
|
|
128
|
+
`,
|
|
129
|
+
text_col: '1 1 1',
|
|
130
|
+
back_col: '1 1 1'
|
|
131
|
+
})
|
|
132
|
+
this.subtitle.objects['3d'].depthWrite = false
|
|
133
|
+
this.subtitle.objects['3d'].depthTest = false
|
|
134
|
+
this.subtitle.objects['3d'].renderOrder = 100
|
|
135
|
+
this.subtitle.setHTML = (html) => {
|
|
136
|
+
this.subtitle.visible = true
|
|
137
|
+
this.subtitle.text = `<div class='subtitle'>${html}</div>`
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
update(){
|
|
142
|
+
if( this.update.id || !this.playing ) return // throttle
|
|
143
|
+
const advance = () => {
|
|
144
|
+
if( !this?.sound?.timeoffset && this?.sound?.audio?.context){
|
|
145
|
+
this.sound.timeoffset = this.sound.audio.context.currentTime
|
|
146
|
+
}
|
|
147
|
+
if( this?.sound?.timeoffset != undefined &&
|
|
148
|
+
this.webvtt?.items[ this.webvtt?.i ] &&
|
|
149
|
+
this?.sound?.playStarted &&
|
|
150
|
+
this?.sound?.audio?.context?.currentTime ){
|
|
151
|
+
|
|
152
|
+
const time = this.sound.audio.context.currentTime - this.sound.timeoffset;
|
|
153
|
+
let item = this.webvtt.items[ this.webvtt.i ]
|
|
154
|
+
if( !item.seen && time > item.start.ts ){
|
|
155
|
+
this.subtitle.setHTML(`
|
|
156
|
+
${ item.who ? `<b>${item.who}:</b><br/>` : '' }
|
|
157
|
+
${item.text.replace(/\n/g,'<br/>')}
|
|
158
|
+
`)
|
|
159
|
+
if( room.hyperlink && item.href && !item.seen ){
|
|
160
|
+
room.hyperlink.execute(item.href, {portalActivateDelay:6000})
|
|
161
|
+
}
|
|
162
|
+
item.seen = true
|
|
163
|
+
}
|
|
164
|
+
if( item.seen && time > item.stop.ts ){
|
|
165
|
+
this.subtitle.visible = false
|
|
166
|
+
this.webvtt.i++
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.update.id = false
|
|
170
|
+
}
|
|
171
|
+
this.update.id = setTimeout( () => advance(), 100 ) // throttle
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// naive webvtt parser
|
|
175
|
+
parseWEBVTT(text) {
|
|
176
|
+
let i = 0;
|
|
177
|
+
const WEBVTT_HEADER = /^WEBVTT/i;
|
|
178
|
+
const TIME_LINE = /^([0-9:.]+)\s+-->\s+([0-9:.]+)(?:\s+(.+))?$/;
|
|
179
|
+
const WHO = /^<v ([^>]+)>/
|
|
180
|
+
const HREF = / href:([^ ]+)/
|
|
181
|
+
const result = { type: "WEBVTT", items: [], i: 0 };
|
|
182
|
+
const lines = text.split(/\r?\n/)
|
|
183
|
+
.map( (line) => line.trim() )
|
|
184
|
+
const vttToSeconds = (vttString) => {
|
|
185
|
+
const parts = vttString.split(':');
|
|
186
|
+
let hours = 0, minutes = 0, secondsWithMs;
|
|
187
|
+
if (parts.length === 3) { [hours, minutes, secondsWithMs] = parts; }
|
|
188
|
+
else { [minutes, secondsWithMs] = parts; } // Format is MM:SS.mmm
|
|
189
|
+
const [seconds, milliseconds] = secondsWithMs.split('.');
|
|
190
|
+
return (
|
|
191
|
+
(parseInt(hours) * 3600) +
|
|
192
|
+
(parseInt(minutes) * 60) +
|
|
193
|
+
(parseInt(seconds) * 1) +
|
|
194
|
+
parseInt(milliseconds || 0)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Skip the WEBVTT header line
|
|
199
|
+
if (WEBVTT_HEADER.test(lines[i].trim())) i++
|
|
200
|
+
while (i < lines.length) {
|
|
201
|
+
if ( lines[i] === "") { i++; continue; } // Skip empty lines
|
|
202
|
+
// Match cue timing line
|
|
203
|
+
const timeMatch = lines[i].match(TIME_LINE);
|
|
204
|
+
if (timeMatch) {
|
|
205
|
+
const item = {
|
|
206
|
+
start: {str:timeMatch[1], ts: vttToSeconds(timeMatch[1]) },
|
|
207
|
+
stop: {str:timeMatch[2], ts: vttToSeconds(timeMatch[2]) },
|
|
208
|
+
href: lines[i].match(HREF) ? lines[i].match(HREF)[1] : false,
|
|
209
|
+
who: false,
|
|
210
|
+
text: ""
|
|
211
|
+
};
|
|
212
|
+
i++;
|
|
213
|
+
const speakerMatch = lines[i]?.match(WHO);
|
|
214
|
+
if (speakerMatch) {
|
|
215
|
+
item.who = speakerMatch[1];
|
|
216
|
+
lines[i] = lines[i].replace(WHO,'');
|
|
217
|
+
}
|
|
218
|
+
// Collect all text lines until next empty line
|
|
219
|
+
while (i < lines.length && lines[i].trim() !== "") {
|
|
220
|
+
item.text += (item.text ? "\n" : "") + lines[i] ;
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
result.items.push(item);
|
|
225
|
+
} else { i++; }
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
start(){
|
|
231
|
+
// reset subtitles
|
|
232
|
+
if( this?.webvtt?.items ){
|
|
233
|
+
this.webvtt.items.map( (item) => item.seen = undefined )
|
|
234
|
+
this.webvtt.i = 0;
|
|
235
|
+
}
|
|
236
|
+
// start sound
|
|
237
|
+
this.sound.pos = '0 0 0'
|
|
238
|
+
if( this.sound.isPlaying ) this.sound.stop()
|
|
239
|
+
this.sound.timeoffset = undefined
|
|
240
|
+
this.sound.play()
|
|
241
|
+
// ensure subtitle
|
|
242
|
+
this.subtitle.visible = false
|
|
243
|
+
player.add(this.subtitle)
|
|
244
|
+
// hide button
|
|
245
|
+
this.btn.visible = false
|
|
246
|
+
this.btn.pickable = false
|
|
247
|
+
// mark playing
|
|
248
|
+
this.playing = true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
stop(){
|
|
253
|
+
this.playing = false
|
|
254
|
+
this.update.id = false
|
|
255
|
+
this.subtitle.visible = false
|
|
256
|
+
this.subtitle.text = ''
|
|
257
|
+
this.btn.visible = true
|
|
258
|
+
this.btn.pickable = true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cleanup(){
|
|
262
|
+
const deleteJsId = (js_id) => {
|
|
263
|
+
for( let i in player.children )
|
|
264
|
+
if( player.children[i].js_id == js_id )
|
|
265
|
+
player.remove( player.children[i] )
|
|
266
|
+
}
|
|
267
|
+
deleteJsId('subtitle')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
})
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
xrf_install_sidecarfiles = function(){
|
|
274
|
+
if( !room.objects?.scene?.modelasset?.loaded ) {
|
|
275
|
+
return setTimeout( xrf_install_sidecarfiles, 300 )
|
|
276
|
+
}
|
|
277
|
+
if( !room.sidecarfile ) room.sidecarfile = new elation.janusweb.sidecarfile(room);
|
|
278
|
+
else{
|
|
279
|
+
room.sidecarfile.positionStartButton() // wait for user being spawned
|
|
280
|
+
room.sidecarfile.stop()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
xrf_install_sidecarfiles()
|
|
285
|
+
|
|
286
|
+
elation.events.add(null, 'room_load_complete', xrf_install_sidecarfiles )
|
|
287
|
+
elation.events.add(null, 'room_enable', xrf_install_sidecarfiles )
|
|
288
|
+
elation.events.add(null, 'janusweb_script_frame', function(){
|
|
289
|
+
if( room?.sidecarfile ) room.sidecarfile.update()
|
|
290
|
+
})
|
|
291
|
+
elation.events.add(null, 'room_load_start', function(e){
|
|
292
|
+
if( !e.data ) return
|
|
293
|
+
if( room?.sidecarfile?.subtitle ) room.sidecarfile.subtitle.setHTML(`<div class='loading'>🔗 ${e.data.name}<br/><br/>please wait..</div>`)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
elation.events.add(null, 'room_disable', function(){
|
|
297
|
+
if( room?.sidecarfile?.subtitle ) room.sidecarfile.stop()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// some convenience WebVTT cue settings => room function mappings
|
|
301
|
+
// href:#fadeAudioOut&spawnhere => room.fadeAudioOut()
|
|
302
|
+
// href:#myfunc=3 => room.myfunc(3)
|
|
303
|
+
elation.events.add(null, 'href', function(e){
|
|
304
|
+
if( room?.hyperlink ){
|
|
305
|
+
const {url,hash} = room.hyperlink.getUrlObject(e.data.href)
|
|
306
|
+
hash.forEach( (v,k) => { if( room[k] ) room[k](v) })
|
|
307
|
+
}
|
|
308
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/* XR URI Fragments spec (https://xrfragment.org)
|
|
2
|
+
*
|
|
3
|
+
* level2 explicit hyperlinks (embedded in 3D scenes/files) as per the XR URI Fragments spec
|
|
4
|
+
*
|
|
5
|
+
* When clicking an href-value, the user(camera) is teleported/imported to the referenced object.
|
|
6
|
+
* Usecases: spatial anchors, object imports, hyperlink bridge between JML<>3D files
|
|
7
|
+
* see more: https://xrfragment.org/#%F0%9F%93%9C%20level2%3A%20explicit%20hyperlinks
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
elation.require([], function() {
|
|
12
|
+
|
|
13
|
+
elation.extend('janusweb.hyperlink', class {
|
|
14
|
+
constructor(object) {
|
|
15
|
+
this.scene = elation.engine.instances.default.systems.world.scene['world-3d']
|
|
16
|
+
this._object = object
|
|
17
|
+
this.init()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
init(){
|
|
21
|
+
this.cleanup()
|
|
22
|
+
this.scan( this.scene )
|
|
23
|
+
this.setupShroud()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setupShroud(){
|
|
27
|
+
// show shroud when teleporting
|
|
28
|
+
for( let i in player.head.children ){
|
|
29
|
+
let n = player.head.children[i]
|
|
30
|
+
if( n.js_id == 'xrf_shroud' ) this.shroud = n // found previrous one
|
|
31
|
+
}
|
|
32
|
+
if( this.shroud ) return // we're done!
|
|
33
|
+
this.shroud = room.createObject('object', {
|
|
34
|
+
id: 'sphere',
|
|
35
|
+
js_id: 'xrf_shroud',
|
|
36
|
+
scale: V(1),
|
|
37
|
+
lighting: false,
|
|
38
|
+
col: 'black',
|
|
39
|
+
cull_face: 'none',
|
|
40
|
+
depth_test: false,
|
|
41
|
+
depth_write: false,
|
|
42
|
+
shadow_cast: false,
|
|
43
|
+
shadow_receive: false,
|
|
44
|
+
renderorder: 1000,
|
|
45
|
+
visible: false,
|
|
46
|
+
});
|
|
47
|
+
player.head.add(this.shroud._target);
|
|
48
|
+
|
|
49
|
+
// patch setPlayerPosition() with shroud animations during local teleports
|
|
50
|
+
room.setPlayerPosition = (
|
|
51
|
+
(original) => function(room){
|
|
52
|
+
this.hyperlink.showShroud()
|
|
53
|
+
return original.apply(this,room)
|
|
54
|
+
}.bind(room)
|
|
55
|
+
)(room.setPlayerPosition)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
scan(scene,cb){
|
|
59
|
+
scene.traverse( (object) => {
|
|
60
|
+
this.detectHUDLUT(object)
|
|
61
|
+
this.detectHref(object)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
detectHref(object){
|
|
66
|
+
if( !object?.userData?.href || object.hasHref ) return
|
|
67
|
+
|
|
68
|
+
const jobj = this.toJanusObject(object)
|
|
69
|
+
jobj.addEventListener("click", () => this.execute(object.userData.href,{jobj,scene:this.scene}) )
|
|
70
|
+
object.hasHref = true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cleanup(){
|
|
74
|
+
const head = player.head.objects['3d']
|
|
75
|
+
head.children
|
|
76
|
+
.filter( (child) => child.xrf_cleanup ? child : false )
|
|
77
|
+
.map( (child) => child.xrf_cleanup() )
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
detectHUDLUT(object){
|
|
81
|
+
// XR Fragment HUD extensions: https://xrfragment.org/#teleport%20camera%20spawnpoint
|
|
82
|
+
if( object.type == 'PerspectiveCamera' && object.name == 'spawn' && object.children.length ){
|
|
83
|
+
const head = player.head.objects['3d']
|
|
84
|
+
// move children to player head
|
|
85
|
+
while( object.children.length ){
|
|
86
|
+
object.children[0].xrf_cleanup = function(me,object){
|
|
87
|
+
object.add( me ) // add back
|
|
88
|
+
}.bind(null, object.children[0], object )
|
|
89
|
+
head.add( object.children[0] )
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
execute = function(href,opts){
|
|
95
|
+
const {url,hash} = this.getUrlObject(href)
|
|
96
|
+
opts = opts ? {...opts, url, hash} : {url,hash}
|
|
97
|
+
console.log("hyperlink: "+href)
|
|
98
|
+
elation.events.fire({element: this, type: 'href', data: {href,opts}});
|
|
99
|
+
if( room.url != url.origin+url.pathname ) return this.executeExternal(href,opts)
|
|
100
|
+
hash.forEach( (v,k) => {
|
|
101
|
+
const {operator,param} = this.getOperators(k)
|
|
102
|
+
switch( param ){
|
|
103
|
+
case "t": // W3C URI Time fragment not implemented (yet)
|
|
104
|
+
case "loop": this._object.loop = operator != '-'
|
|
105
|
+
case "pos": // legacy fallthrough
|
|
106
|
+
default: // level2: internal teleports/spawn
|
|
107
|
+
// https://xrfragment.org/#%F0%9F%93%9C%20level2%3A%20explicit%20hyperlinks
|
|
108
|
+
room.referrer = room.urlhash
|
|
109
|
+
room.urlhash = v || k
|
|
110
|
+
if( this.scene.getObjectByName(v) ){
|
|
111
|
+
room.setPlayerPosition()
|
|
112
|
+
this.spawnBackLink()
|
|
113
|
+
}
|
|
114
|
+
// level2: animation triggers
|
|
115
|
+
// https://xrfragment.org/#%F0%9F%93%9C%20level2%3A%20explicit%20hyperlinks
|
|
116
|
+
for( let i in this._object.children ){
|
|
117
|
+
const model = this._object.children[i]
|
|
118
|
+
if( model.animations && model.animations.map ){
|
|
119
|
+
model.animations.map( (a) => {
|
|
120
|
+
if( !String(a.name).toLowerCase() == String(v).toLowerCase() ) return
|
|
121
|
+
console.log("xrfragment: activating animation '"+a.name+"'")
|
|
122
|
+
model.anim_id = a.name
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const fullUrl = href.match(/^#/) ? `${room.url}${url.hash}` : url.href
|
|
132
|
+
room.url = fullUrl
|
|
133
|
+
if( !url.protocol.match(/^xrf:/) ){
|
|
134
|
+
// level4: https://xrfragment.org/doc/RFC_XR_Fragments.html#xrf-uri-scheme
|
|
135
|
+
janus.updateClientURL(fullUrl)
|
|
136
|
+
}
|
|
137
|
+
elation.events.fire({element: room, type: 'room_change', data: room});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
spawnBackLink(){
|
|
141
|
+
for( let i in room.objects ){
|
|
142
|
+
if( i.match(/^reciprocal-hashlink/) ) room.removeObject(i)
|
|
143
|
+
}
|
|
144
|
+
let link = room.createObject('link', {
|
|
145
|
+
rotation: '0 45 0',
|
|
146
|
+
url: room.url+'#'+room.referrer,
|
|
147
|
+
title: 'go back',
|
|
148
|
+
round: true,
|
|
149
|
+
shader_id: 'defaultportal',
|
|
150
|
+
js_id: 'reciprocal-hashlink',
|
|
151
|
+
});
|
|
152
|
+
setTimeout( () => {
|
|
153
|
+
link.position = player.localToWorld(V(1.5,0,1.5))
|
|
154
|
+
},10)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
executeExternal(href, opts){
|
|
158
|
+
if( opts.portalActivateDelay ){
|
|
159
|
+
setTimeout( () => {
|
|
160
|
+
janus.setActiveRoom( href, room.url)
|
|
161
|
+
}, opts.portalActivateDelay)
|
|
162
|
+
}
|
|
163
|
+
player.spawnPortal(href)
|
|
164
|
+
elation.events.fire({element: this, type: 'href_portal', data: {href,opts}});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getUrlObject = function(href){
|
|
168
|
+
const urlExpanded = href[0] == '#'
|
|
169
|
+
? room.url+href
|
|
170
|
+
: href.match(/:\//) ? href : room.baseurl+href
|
|
171
|
+
const url = new URL( urlExpanded )
|
|
172
|
+
const hash = new URLSearchParams( String(url.hash).substr(1) )
|
|
173
|
+
return {url,hash}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getOperators = function(k){
|
|
177
|
+
let operator = ''
|
|
178
|
+
// scan for operator
|
|
179
|
+
if( k[0].match(/[-+]/) ){
|
|
180
|
+
operator = k[0]
|
|
181
|
+
k = k.substr(1)
|
|
182
|
+
}
|
|
183
|
+
return {operator,param:k}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
toJanusObject = function(object){
|
|
187
|
+
// we are not using object.parts[ ... ] because
|
|
188
|
+
// glTF animated collidable objects require special sync/setup:
|
|
189
|
+
// collider must be child of THREE object (to get animated), not janusobject
|
|
190
|
+
// NOTE: avoid jobj.add() since that reparents the object (andw breaks glTF anims)
|
|
191
|
+
let jo = this._object.createObject("object",{
|
|
192
|
+
js_id: object.name
|
|
193
|
+
})
|
|
194
|
+
object.parent.add( jo.objects['3d'] )
|
|
195
|
+
jo.objects['3d'] = object
|
|
196
|
+
jo.collidable = true
|
|
197
|
+
jo.removeCollider();
|
|
198
|
+
const collider = object.clone()
|
|
199
|
+
collider.position.set(0,0,0)
|
|
200
|
+
collider.rotation.set(0,0,0)
|
|
201
|
+
collider.scale.set(1,1,1)
|
|
202
|
+
jo.setCollider('mesh',{mesh: collider})
|
|
203
|
+
jo.colliders.parent = object
|
|
204
|
+
return jo
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
showShroud = function(){
|
|
208
|
+
this.shroud.visible = true;
|
|
209
|
+
this.shroud.opacity = 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
update = function(){
|
|
213
|
+
if (this.shroud?.visible && !janus.loading) {
|
|
214
|
+
if (this.shroud.opacity > .001) {
|
|
215
|
+
this.shroud.opacity *= .9;
|
|
216
|
+
if (this.shroud.opacity <= .001) {
|
|
217
|
+
this.shroud.visible = false;
|
|
218
|
+
this.shroud.opacity = 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
})
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
xrf_install_hyperlinks = function(){
|
|
228
|
+
if( !room.objects?.scene?.modelasset?.loaded ) {
|
|
229
|
+
return setTimeout( xrf_install_hyperlinks, 300 )
|
|
230
|
+
}
|
|
231
|
+
if( !room.hyperlink ){
|
|
232
|
+
room.hyperlink = new elation.janusweb.hyperlink(room)
|
|
233
|
+
janus.loading = false // force!
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
xrf_install_hyperlinks()
|
|
238
|
+
|
|
239
|
+
// update urlbar when user or browser activates href
|
|
240
|
+
elation.events.add(null, 'href', function(e){
|
|
241
|
+
const scene = elation.engine.instances.default.systems.world.scene['world-3d']
|
|
242
|
+
const urlbar = document.querySelector('janus-ui-urlbar ui-input')
|
|
243
|
+
const href = e?.data?.href
|
|
244
|
+
if( urlbar ){
|
|
245
|
+
urlbar.value = href[0] == '#' ? urlbar.value.replace(/#.*/,'') + href : href
|
|
246
|
+
}else console.warn("xrfragment: cannot find urlbar")
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
elation.events.add(null, 'room_load_complete', xrf_install_hyperlinks )
|
|
250
|
+
elation.events.add(null, 'room_disable', function(e){
|
|
251
|
+
if( room?.hyperlink ) room.hyperlink.cleanup()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
elation.events.add(null, 'room_enable', function(e){
|
|
255
|
+
if( room?.hyperlink ) room.hyperlink.init()
|
|
256
|
+
else xrf_install_hyperlinks()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
elation.events.add(null, 'janusweb_script_frame', function(){
|
|
260
|
+
if( room?.hyperlink ) room.hyperlink.update()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
if( room.urlhash ){
|
|
264
|
+
elation.events.fire({element: this, type: 'href', data: {href: `#${room.urlhash}`}});
|
|
265
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
xrf_export = {
|
|
2
|
+
|
|
3
|
+
node(e){
|
|
4
|
+
const {userData,n} = e.data
|
|
5
|
+
for( let k in userData ){
|
|
6
|
+
if( k.match(/^-(three)-/) || k == 'href' ){ // keep hrefs + `-three-*` engine prefixes
|
|
7
|
+
n.userData[k] = userData[k] // but ditch `-janus-*` (we'll regenerate them)
|
|
8
|
+
}
|
|
9
|
+
if( k == 'thing' && String(userData[k].componentname).match('engine.things.janus') ){
|
|
10
|
+
const thing = userData[k]
|
|
11
|
+
switch( thing.componentname ){
|
|
12
|
+
case "engine.things.janusparagraph":
|
|
13
|
+
case "engine.things.janustext":
|
|
14
|
+
case "engine.things.janusportal":
|
|
15
|
+
case "engine.things.januswebsurface":
|
|
16
|
+
case "engine.things.janusroom":
|
|
17
|
+
|
|
18
|
+
if( thing.componentname != 'engine.things.janusroom' ){
|
|
19
|
+
n.name = String(`-janus-${thing.js_id}`).replace(/.*janus-/,'-janus-')
|
|
20
|
+
n.userData['-janus-tag'] = thing.componentname
|
|
21
|
+
.replace('janusportal','januslink')
|
|
22
|
+
.replace('engine.things.janus','')
|
|
23
|
+
}
|
|
24
|
+
if( thing.componentname.match(/engine\.things\.janus(portal|link)/) && n.children[0] ){
|
|
25
|
+
// convert portal to XRF href
|
|
26
|
+
n.children[0].userData['href'] = thing.url
|
|
27
|
+
}
|
|
28
|
+
// write XRF `-janus-*` engine prefixes
|
|
29
|
+
const attrs = xrf_export.getAttributes(thing)
|
|
30
|
+
for( let i in attrs ){
|
|
31
|
+
if( !i.match(/^-janus-(use_local_asset)/) ){
|
|
32
|
+
n.userData[`-janus-${i}`] = attrs[i]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
scene(e){
|
|
41
|
+
// https://xrfragment.org/#%F0%9F%93%9Clevel7%3A%20engine%20prefixes
|
|
42
|
+
e.data.scene.userData['-janus-source'] = room.getRoomSource()
|
|
43
|
+
},
|
|
44
|
+
getAttributes(thing){
|
|
45
|
+
if( !thing ) return {}
|
|
46
|
+
let proxy = thing.getProxyObject(),
|
|
47
|
+
propdefs = thing._thingdef.properties,
|
|
48
|
+
proxydefs = proxy._proxydefs,
|
|
49
|
+
attrs = {};
|
|
50
|
+
|
|
51
|
+
// this code is almost a duplicate of elation's .summarizeXML() but with inversed (glTF) position
|
|
52
|
+
for (let k in proxydefs) {
|
|
53
|
+
let proxydef = proxydefs[k],
|
|
54
|
+
propdef = elation.utils.arrayget(propdefs, proxydef[1]);
|
|
55
|
+
if ( k != 'room' && k != 'tagName' && k != 'classList' && proxydef[0] == 'property' && propdef) {
|
|
56
|
+
let val = elation.utils.arrayget(thing.properties, proxydef[1]);
|
|
57
|
+
let defaultval = propdef.default;
|
|
58
|
+
if (val instanceof THREE.Vector2) {
|
|
59
|
+
if (defaultval instanceof THREE.Vector2) defaultval = defaultval.toArray();
|
|
60
|
+
if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1]))) {
|
|
61
|
+
attrs[k] = val.toArray().map(n => Math.round(n * 10000) / 10000).join(' ');
|
|
62
|
+
}
|
|
63
|
+
} else if (val instanceof THREE.Vector3) {
|
|
64
|
+
if (defaultval instanceof THREE.Vector3) defaultval = defaultval.toArray();
|
|
65
|
+
if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1] && val.z == defaultval[2]))) {
|
|
66
|
+
attrs[k] = val.toArray().map(n => Math.round(n * -10000) / 10000).join(' ');
|
|
67
|
+
}
|
|
68
|
+
} else if (val instanceof THREE.Color) {
|
|
69
|
+
if (defaultval instanceof THREE.Color) defaultval = defaultval.toArray();
|
|
70
|
+
if (!('default' in propdef) || defaultval === null || ('default' in propdef && !(val.r == defaultval[0] && val.g == defaultval[1] && val.b == defaultval[2]))) {
|
|
71
|
+
attrs[k] = val.toArray().map(n => Math.round(n * 10000) / 10000).join(' ');
|
|
72
|
+
}
|
|
73
|
+
} else if (val instanceof THREE.Euler) {
|
|
74
|
+
if (defaultval instanceof THREE.Euler) defaultval = defaultval.toArray();
|
|
75
|
+
if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1] && val.z == defaultval[2]))) {
|
|
76
|
+
attrs[k] = val.toArray().slice(0, 3).map(n => Math.round(n * 10000) / 10000).join(' ');
|
|
77
|
+
}
|
|
78
|
+
} else if (val instanceof THREE.Quaternion) {
|
|
79
|
+
if (defaultval instanceof THREE.Quaternion) defaultval = defaultval.toArray();
|
|
80
|
+
if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1] && val.z == defaultval[2] && val.w == defaultval[3]))) {
|
|
81
|
+
attrs[k] = val.toArray().map(n => Math.round(n * 10000) / 10000).join(' ');
|
|
82
|
+
}
|
|
83
|
+
} else if (val !== propdef.default && val !== null && val !== '') {
|
|
84
|
+
attrs[k] = val;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return attrs;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
elation.events.add(null, 'webui_editor_export_node', xrf_export.node )
|
|
93
|
+
elation.events.add(null, 'webui_editor_export', xrf_export.scene )
|