<!DOCTYPE html>
< html >
< title > SpaSca : Spatial Scaffolding< / title >
< head >
<!-- Suggestions? https://git.benetou.fr/utopiah/text - code - xr - engine/issues/ -->
< script src = 'dependencies/aframe.offline.min.js' > < / script >
< script src = "dependencies/a-console.js" > < / script >
< script src = 'dependencies/aframe-troika-text.min.js' > < / script >
< script src = 'dependencies/webdav.js' > < / script >
< script src = "https://cdn.jsdelivr.net/gh/c-frame/aframe-extras@7.1.0/dist/aframe-extras.min.js" > < / script >
< script src = "https://cdn.jsdelivr.net/gh/c-frame/aframe-physics-system@v4.2.2/dist/aframe-physics-system.min.js" > < / script >
< script src = 'jxr-core.js?12345' > < / script >
< script src = 'jxr-postitnote.js?13235' > < / script >
< / head >
< body >
< script >
/* TODO :
- refactor to use emit('eventname', {eventdata:'data'}) for onreleased and onpicked rather than latest element
e.g newEl.setAttribute("onreleased", 'document.querySelector("['+generatorName+']").emit("check",{color:"'+color+'"})')
let latest = selectedElements[selectedElements.length-1].element
check for this pattern and replace by event.detail.element instead (for now e.detail.element)
might want to reconsider the el convention and do e.g let eventFromEl = e.detail.element over the eval() scope
- insure scene setup, e.g starting position and orientation of environment and main character (until now assumed unchanged)
- isolate emit('eventname', {test:0}) versus same with onreleased (which does NOT work) and same without event detail (which works)
- add audio instructions
../content/voicesBigguJulia/simon_instructions_fr.mp3
../content/voicesBigguJulia/labyrinthe_instructions_fr.mp3
../content/voicesBigguJulia/lettresprenom_instructions_fr.mp3
../content/voicesBigguJulia/tableauformes_instructions_fr.mp3
already there
../content/voicesBigguJulia/instructions.mp3
../content/voicesBigguJulia/bravojulia.mp3
../content/voicesBigguJulia/continu.mp3
../content/voicesBigguJulia/biggu-fem.mp3
example : tts --text "Regarde la couleur, ecoute le son, et pince avec ta main droite chaque cube dans le meme ordre" --model_name "tts_models/fr/mai/tacotron2-DDC" --out_path story.wav
Regarde la couleur, ecoute le son, et pince avec ta main droite chaque cube dans le meme ordre
Aide-moi a atteindre le poisson a la sortie du labyrinthe. Pour cela trouve les instructions pour me faire avancer, il y a en 4. Avec ta main gauche pince la premiere lettre et je bougerais dans cette direction !
Oops, les formes sont toutes melangees. Merci de les remettre a leur place en faisant attention que chaque fois ai la bonne couleur, comme indique par la ligne du haut et la colonne de droite. Pour deplacer les formes colorees pince les avec ta main droite.
Oh non, les lettres de ton prenom ont ete melangees. Pince chacune avec ta main droite et pose les dans le cube dans le bon ordre
- reset (as done for fishinbowl)
- better menu (e.g target with onreleased)
- fix maze/mazemap mismatch (causing emit() error on init)
- game ideas
- art deco / art nouveau facade as puzzle mixed pieces
- philosophical experimentation, cf https://video.benetou.fr/w/9KGbaxtAEx4JLnhAkwQKC1 featuring then the trolley problem
*/
AFRAME.registerComponent('startfunctions', {
init: function(){
let untestedGames = ["checkers", "carcassone"]
addGame("voxelpaint")
addGame("simon")
addGame("physics-construct")
addGames()
}
})
//___________________________________________________________________________________________________________________________________
/*
game manager component
parent entity where each game itself is another child entity
menu
show/hide each game
bookmark
filter on e.g age range, last played, not completed
has listener to unify animation and audio
e.g yes/win or try again
but also lets custom content be presented, e.g custom audio instructions
*/
function addGame(gamename, visible="false"){
let newEl = document.createElement('a-entity')
newEl.id = gamename
newEl.setAttribute(gamename, "")
newEl.setAttribute("visible", visible)
newEl.classList.add( "game" )
AFRAME.scenes[0].appendChild(newEl)
}
function addGames(){
const imgPath = "../content/games/previews/"
const imgExtension = ".jpg"
// show/hide should be enough (target should only work when shown iirc)
Array.from( document.querySelectorAll('.game') ).map( (g,i) => {
let n = addNewNote("jxr showOnlyThisGame('"+g.id+"')")
AFRAME.scenes[0].appendChild(n)
setTimeout( _ => {
let newEl = document.createElement("a-image")
newEl.setAttribute("src", imgPath+g.id+imgExtension)
//newEl.setAttribute("position", "-1 0 0")
newEl.setAttribute("target", "true") // now works despite relative position... but weird
newEl.setAttribute("onreleased", "showOnlyThisGame('"+g.id+"')")
n.appendChild( newEl )
n.object3D.position.y+=i/10
// n.setAttribute("annotation", "content:...")
// e.g to add French, would need to add specific data e.g full name, translation with language name e.g FR, etc
}, 500 )
})
// also need to add reset state!
// could add a reset event listener on each component
}
function showOnlyThisGame(name){
// should also work via URL, e.g hash or query parameter
Array.from( document.querySelectorAll('.game') ).map( (g,i) => g.setAttribute("visible", "false") )
document.getElementById(name).setAttribute("visible", "true")
document.querySelector("["+name+"]").emit("reset")
}
//___________________________________________________________________________________________________________________________________
AFRAME.registerComponent('throw-on-drop', {
init: function(){
let generatorName = this.attrName
let el = this.el
AFRAME.scenes[0].setAttribute("physics", "debug:true")
el.setAttribute("onpicked", 'window.pfp = e.detail.element.getAttribute("position").clone(); e.detail.element.removeAttribute("dynamic-body")')
// could also start an interval instead
el.setAttribute("onreleased", 'toss( e.detail.element )')
}
})
function toss(el, forcedvec){
// time as force multiplier, i.e .1m/s vs .1m/10s
// need to have another variable storing the time when window.pfp changed
//AFRAME.scenes[0].setAttribute("physics", "debug:true")
el.setAttribute("dynamic-body", "")
//el.body.applyImpulse( forcedvec, new CANNON.Vec3().copy(el.getAttribute('position')) )
//el.body.applyForce( forcedvec, new CANNON.Vec3().copy(el.getAttribute('position')) )
// tested via toss( document.querySelector("[throw-on-drop]"), new THREE.Vector3(0,1,-1) )
let pos = new THREE.Vector3().copy( el.getAttribute('position') )
pos.sub( window.pfp )
let vec = new CANNON.Vec3().copy( pos )
console.log("sub", vec ) // probably have to be normalized
//el.body.applyForce( vec, new CANNON.Vec3().copy(el.getAttribute('position')) )
el.body.applyImpulse( vec, new CANNON.Vec3().copy(el.getAttribute('position')) )
// cf https://schteppe.github.io/cannon.js/docs/classes/Body.html#method_applyImpulse
// could try alternatives, e.g applyForce ()
// kind of works... but no notion of power inherited from speed
// also the direction is from start from finish, which is not what others expect
// except if shown, e.g with helper arrow
// could clone and preview movement at interval
// does not move if we pick and release in place
}
/*
physics https://github.com/c-frame/aframe-physics-system and setup docs https://github.com/c-frame/aframe-physics-system/blob/master/CannonDriver.md#installation
should append to head script with src="https://cdn.jsdelivr.net/gh/c-frame/aframe-physics-system@v4.2.2/dist/aframe-physics-system.min.js"
<!-- Floor -->
< a-plane static-body > < / a-plane >
<!-- Immovable box -->
< a-box static-body position = "0 0.5 -5" width = "3" height = "1" depth = "1" > < / a-box >
<!-- Dynamic box -->
< a-box dynamic-body position = "5 0.5 0" width = "1" height = "1" depth = "1" > < / a-box >
todo
test getVoxelPoses() to hash equivalent, in order to be able to share builds
*/
AFRAME.registerComponent('physics-construct', {
init: function(){
let generatorName = this.attrName
let el = this.el
let sphereEl = document.createElement('a-sphere')
sphereEl.setAttribute('radius', '.01')
sphereEl.setAttribute('target', '')
sphereEl.setAttribute('position', '0 1 -.5')
sphereEl.setAttribute("onpicked", "window.pfb = selectedElements.at(-1).element.getAttribute('position').clone();")
sphereEl.setAttribute("onreleased", "document.querySelector('["+generatorName+"]').components['"+generatorName+"'].twoPosToBox(window.pfb, selectedElements.at(-1).element.getAttribute('position'), '"+generatorName+"');")
// really verbose... consider rebinding or more helpers in such events, facilitating access to this.el and component overall
AFRAME.scenes[0].appendChild(sphereEl)
let ballEl = document.createElement('a-sphere')
ballEl.setAttribute('radius', '.01')
ballEl.setAttribute('color', 'blue')
ballEl.setAttribute('target', '')
ballEl.setAttribute('position', '0 1.5 -0.2')
//ballEl.setAttribute('dynamic-body', '') // does not fall?
ballEl.setAttribute("onpicked", 'e.detail.element.removeAttribute("dynamic-body")') // untested
ballEl.setAttribute("onreleased", 'e.detail.element.setAttribute("dynamic-body","")')
AFRAME.scenes[0].appendChild(ballEl)
AFRAME.scenes[0].setAttribute("physics", "debug:true")
//let script = document.createElement("script")
//script.setAttribute("src", "https://cdn.jsdelivr.net/gh/c-frame/aframe-physics-system@v4.2.2/dist/aframe-physics-system.min.js")
//document.head.appendChild(script) // does not work, check older tricks
if (window.location.hash) {
let poses = JSON.parse(decodeURI(window.location.hash.replace("#",'')))[generatorName]
// prefixed by generatorName in order to support saving/sharing of the state of other games
poses?.map( p => {
let newEl = document.createElement('a-box')
newEl.setAttribute("scale", p.scale)
newEl.setAttribute("position", p.position)
newEl.setAttribute("rotation", p.rotation)
newEl.setAttribute("target","true")
newEl.setAttribute('static-body', '')
newEl.setAttribute("onreleased", "document.querySelector('["+generatorName+"]').emit('getVoxelPoses')")
newEl.classList.add( "voxel_" + generatorName )
newEl.classList.add( "voxel" )
newEl.classList.add( generatorName )
AFRAME.scenes[0].appendChild(newEl)
})
}
},
events: {
reset: function (evt) {
console.log(this.attrName, 'component was resetted!');
let generatorName = this.attrName
Array.from( this.el.querySelectorAll("."+generatorName) ).map( el => deleteTarget(el) )
},
check: function (evt) {
},
getVoxelPoses: function (evt){
console.log('generating poses')
let generatorName = this.attrName
let poses = []
// to clarify querySelectorAll here is done on the element and thus should no interfer with other components using the same mechanism
// Array.from( this.el.querySelectorAll(".voxel") ).map( el => {
Array.from( AFRAME.scenes[0].querySelectorAll(".voxel_" + generatorName ) ).map( el => {
poses.push( {
position: this.shortenVector3( el.getAttribute("position") ),
rotation: this.shortenVector3( el.getAttribute("rotation") ),
scale: this.shortenVector3( el.getAttribute("scale") ),
} )
})
let data = {}
data[generatorName] = poses
window.location.hash = JSON.stringify(data)
console.log('generated poses:', poses.length)
// prefixed by generatorName in order to support saving/sharing of the state of other games
},
},
shortenVector3: function ( v ){
let o = new THREE.Vector3()
o.x = v.x.toFixed(3)
o.y = v.y.toFixed(3)
o.z = v.z.toFixed(3)
return o
},
twoPosToBox(A, B, generatorName){
let center = A.clone()
center.add(B)
center.divideScalar(2)
let lengthes = A.clone()
lengthes.sub(B)
let newEl = document.createElement("a-box")
newEl.setAttribute("position", center )
newEl.setAttribute('target', '')
newEl.setAttribute('static-body', '')
newEl.classList.add( "voxel" )
newEl.classList.add( generatorName )
newEl.classList.add( "voxel_" + generatorName )
newEl.setAttribute("scale", lengthes.toArray().map( i => Math.abs(i) ).join(" ") )
newEl.setAttribute("onreleased", "document.querySelector('["+generatorName+"]').emit('getVoxelPoses')")
AFRAME.scenes[0].appendChild(newEl)
// should parent to the component element instead...
return newEl
}
})
//___________________________________________________________________________________________________________________________________
AFRAME.registerComponent('voxelpaint', {
init: function(){
let generatorName = this.attrName
let el = this.el
this.colors = ["red", "green", "blue", "yellow" ]
this.scale = 1/10
this.yOffset = 1.5
let j = 0
this.colors.map( (color, i) => {
let newEl = document.createElement('a-box')
newEl.setAttribute("scale", ""+this.scale+" "+this.scale+" "+this.scale)
newEl.setAttribute("color",color)
newEl.setAttribute("position", ""+(i%2)*this.scale+" "+(this.yOffset+j*this.scale)+" -.5")
newEl.setAttribute("target","true")
newEl.setAttribute("onpicked", "document.querySelector('["+generatorName+"]').emit('check')")
newEl.setAttribute("onreleased", "document.querySelector('["+generatorName+"]').emit('getVoxelPoses')")
newEl.classList.add( generatorName )
el.appendChild(newEl)
if (i==1) j++
})
// check if data is present, as hash or query parameter, and if so, display accordingly
if (window.location.hash) {
let poses = JSON.parse(decodeURI(window.location.hash.replace("#",'')))[generatorName]
// prefixed by generatorName in order to support saving/sharing of the state of other games
poses.map( p => {
let newEl = document.createElement('a-box')
newEl.setAttribute("scale", ""+this.scale+" "+this.scale+" "+this.scale)
newEl.setAttribute("color",p.color)
newEl.setAttribute("position", p.position)
newEl.setAttribute("rotation", p.rotation)
newEl.setAttribute("target","true")
newEl.setAttribute("onreleased", "document.querySelector('["+generatorName+"]').emit('getVoxelPoses')")
newEl.classList.add( "voxel" )
newEl.classList.add( generatorName )
el.appendChild(newEl)
})
}
},
events: {
reset: function (evt) {
console.log(this.attrName, 'component was resetted!');
let generatorName = this.attrName
Array.from( this.el.querySelectorAll(".voxel") ).map( el => el.remove() )
},
check: function (evt) {
let generatorName = this.attrName
let latest = selectedElements[selectedElements.length-1].element
let newEl = latest.cloneNode(true) // does not seem to properly clone all attributes, e.g color works but not position or scale
// ["scale", "position", "onpicked"].map( prop => console.log( prop)) //newEl.setAttribute(prop, latest.getAttribute(prop) )
newEl.setAttribute("scale", latest.getAttribute("scale") )
newEl.setAttribute("position", latest.getAttribute("position") )
newEl.setAttribute("onpicked", latest.getAttribute("onpicked") )
latest.removeAttribute("onpicked")
latest.classList.add( "voxel" )
this.el.appendChild( newEl )
// could also snap it back, e.g clear rotation, possibily use initially position
},
getVoxelPoses: function (evt){
let generatorName = this.attrName
let poses = []
Array.from( this.el.querySelectorAll(".voxel") ).map( el => {
poses.push( {
position: this.shortenVector3( el.getAttribute("position") ),
rotation: this.shortenVector3( el.getAttribute("rotation") ),
color: el.getAttribute("color")
} )
})
let data = {}
data[generatorName] = poses
window.location.hash = JSON.stringify(data)
// prefixed by generatorName in order to support saving/sharing of the state of other games
}
},
shortenVector3: function ( v ){
let o = new THREE.Vector3()
o.x = v.x.toFixed(3)
o.y = v.y.toFixed(3)
o.z = v.z.toFixed(3)
return o
}
})
//___________________________________________________________________________________________________________________________________
AFRAME.registerComponent('simon', {
init: function(){
let generatorName = this.attrName
let el = this.el
this.colors = ["red", "green", "blue", "yellow" ]
const notePrefix = '../content/notes/t'
const noteSuffix = '.mp3'
this.sequence = []
this.posInSeq = -1
this.userSeq = []
this.scale = 1/10
let j = 0
this.colors.map( (color, i) => {
let newEl = document.createElement('a-box')
newEl.setAttribute("scale", ""+this.scale+" "+this.scale+" "+this.scale)
newEl.setAttribute("color",color)
newEl.setAttribute("opacity", .5)
newEl.setAttribute("sound", "src:url("+notePrefix+(i+1)+noteSuffix+")")
newEl.setAttribute("position", ""+(i%2)*this.scale+" 1.1 "+j*this.scale)
newEl.setAttribute("target","true")
newEl.setAttribute("onreleased", 'document.querySelector("['+generatorName+']").emit("check",{color:"'+color+'"})')
newEl.classList.add( generatorName )
el.appendChild(newEl)
if (i==1) j++
})
//setTimeout( _ => { document.querySelector("["+generatorName+"]").emit('playSequence') }, 1000)
},
events: {
reset: function (evt) {
console.log(this.attrName, 'component was resetted!');
let generatorName = this.attrName
this.sequence = []
this.posInSeq = -1
this.userSeq = []
// could also reposition the boxes
clearInterval( this.interval )
setTimeout( _ => { document.querySelector("["+generatorName+"]").emit('playSequence') }, 1000)
},
check: function (evt) {
// could also snap it back, e.g clear rotation, possibily use initially position
let generatorName = this.attrName
this.userSeq.push(evt.detail.color)
let box = this.el.querySelector("a-box[color="+evt.detail.color+"]")
box.components.sound.playSound()
console.log ('seq:', this.sequence.at( this.userSeq.length-1) ,'user:', this.userSeq.at(-1) )
if (this.userSeq.at(-1) == this.sequence.at( this.userSeq.length-1) ){
console.log('same', this.sequence, this.userSeq)
if (this.userSeq.length == this.sequence.length){
console.log('entire sequence complete', this.sequence, this.userSeq)
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_win')
this.userSeq = []
document.querySelector("["+generatorName+"]").emit('playSequence') // grow sequence
} else {
console.log('partial sequence only, waiting for new input')
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_yes')
}
} else {
document.querySelector("["+generatorName+"]").emit('reset')
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_no')
console.log('failed, should reset')
}
},
playSequence: function(evt) {
this.interval = setInterval( _ => {
this.posInSeq++
if (this.posInSeq == this.sequence.length){
this.sequence.push( this.colors.at( this.colors.length * Math.random() ) )
clearInterval( this.interval )
setTimeout( _ => { this.posInSeq=-1 }, 100)
// should also a timeout to start giving answers
}
let box = this.el.querySelector("a-box[color="+ this.sequence[ this.posInSeq ] + "]")
box.setAttribute("opacity", 1)
box.components.sound.playSound()
setTimeout( _ => { box.setAttribute("opacity", .5) }, 700)
}, 1000)
}
}
})
//___________________________________________________________________________________________________________________________________
AFRAME.registerComponent('carcassone', {
init: function(){
// written vertically then joined, corners, bridges, crosses
let tiles = [ "00100 01100 11011 00110 00100", "00100 00100 10101 00100 00100", "00100 00100 11111 00100 00100", ]
this.colors = ['red', 'green', 'blue', 'yellow']
let generatorName = this.attrName
let el = this.el
let deckOfTiles = []
for (let i=0; i< 4 ; i + + )
deckOfTiles.push( tiles[2] )
for (let i=0; i< 3 ; i + + )
this.colors.map( (c,i) => {
let t = tiles[1].replace('10101','10'+(i+2)+'01') // put in the center, easier, but could be a random one bridge, center vertical
deckOfTiles.push( t )
})
let colorMixes = []
for (let i=1; i< 4 ; i + + )
colorMixes.push( [0,i] )
for (let i=2; i< 4 ; i + + )
colorMixes.push( [1,i] )
colorMixes.push( [2,3] )
for (let i=0; i< 2 ; i + + )
colorMixes.map( cs => {
let t = tiles[0].replace('11011','1'+(cs[0]+2)+'0'+(cs[1]+2)+'1')
deckOfTiles.push( t )
})
for (let i=0; i< 2 ; i + + )
this.colors.map( (c,i) => {
let t = tiles[0].replace('01100','01'+(i+2)+'00')
deckOfTiles.push( t )
})
// TODO add the item per color, should try to make minimalist fishes, e.g cone for tail the flatten sphere for body
// test to generate tiles
let stepSize = 1/2
deckOfTiles.map( (tile,n) => {
let t = this.tileFromData( tile )
t.setAttribute("position", "0 0 "+(n*stepSize))
el.appendChild( t )
})
},
tileFromData: function(tileData){
let generatorName = this.attrName
let tileEl = document.createElement("a-entity")
tileData.split(" ").filter(l=>l.length>0).map( (line,i) => {
let whatever = [...line.trim()].map( (c,j) =>{
let newEl = document.createElement("a-box")
newEl.setAttribute("scale", ".1 .1 .1")
let color
let pieceColor
switch (Number(c)){
case 0:
color="blue"
newEl.setAttribute("height", 2)
break;
case 1:
color="white"
break;
// could do Number(c) to be able to check if >1 as fish on tile (with potential a random rotation)
case 2:
case 3:
case 4:
case 5:
color="white"
pieceColor = this.colors[Number(c)-2]
break;
}
if (pieceColor){
let pieceEl = document.createElement('a-cylinder')
pieceEl.setAttribute("radius", .4)
pieceEl.setAttribute("height", .1)
pieceEl.setAttribute("color", pieceColor)
pieceEl.setAttribute("position", "0 1 0")
pieceEl.classList.add( generatorName )
newEl.appendChild(pieceEl)
}
newEl.setAttribute("color", color)
newEl.setAttribute("position", ""+j/10+" 0 "+i/10)
tileEl.appendChild(newEl)
})
})
return tileEl
},
events: {
reset: function (evt) {
console.log(this.attrName, 'component was resetted!');
},
check: function (evt) {
let generatorName = this.attrName
}
}
})
//___________________________________________________________________________________________________________________________________
AFRAME.registerComponent('checkers', {
init: function(){
let generatorName = this.attrName
let el = this.el
let color = "white"
this.scale = 1/10
for (let j=0;j< 8 ; j + + ) {
for (let i=0;i< 8 ; i + + ) {
let newEl = document.createElement('a-box')
newEl.setAttribute("scale", ""+this.scale+" "+this.scale/10+" "+this.scale)
color=="white"?color="black":color="white"
newEl.setAttribute("color",color)
newEl.setAttribute("position", ""+i*this.scale+" 0.1 "+j*this.scale)
newEl.classList.add( generatorName )
el.appendChild(newEl)
if (j< 2 ) {
let pieceEl = document.createElement('a-cylinder')
pieceEl.setAttribute("radius", .04)
pieceEl.setAttribute("height", .1)
pieceEl.setAttribute("target", "true")
pieceEl.setAttribute("color","#555555")
pieceEl.setAttribute("position", ""+i*this.scale+" 0.1 "+j*this.scale)
pieceEl.classList.add( generatorName )
el.appendChild(pieceEl)
}
if (j>=6){
let pieceEl = document.createElement('a-cylinder')
pieceEl.setAttribute("radius", .04)
pieceEl.setAttribute("height", .1)
pieceEl.setAttribute("target", "true")
pieceEl.setAttribute("color","#EEEEEE")
pieceEl.setAttribute("position", ""+i*this.scale+" 0.1 "+j*this.scale)
pieceEl.classList.add( generatorName )
el.appendChild(pieceEl)
}
}
color=="white"?color="black":color="white"
}
},
events: {
reset: function (evt) {
console.log(this.attrName, 'component was resetted!');
},
check: function (evt) {
let generatorName = this.attrName
}
}
})
//___________________________________________________________________________________________________________________________________
// model component so far, single setup and single check
AFRAME.registerComponent('fishinbowl', {
init: function(){
let generatorName = this.attrName
let el = this.el
this.correctlyPlacedFishes = 0
this.maxFishes = 5
this.xOffset = -.1
this.yOffset = .5
this.zOffset = -.1
this.scale = 1/1
for (let i=0;i< this.maxFishes ; i + + ) {
let newEl = document.createElement('a-gltf-model')
newEl.setAttribute("onreleased", 'document.querySelector("['+generatorName+']").emit("check")')
// this is THE key part, namely that when we pinch, move then release that target, it checks the state of the component
newEl.setAttribute("target","true")
newEl.setAttribute("scale",".001 .001 .001")
newEl.setAttribute("src","../content/winterset/Fish.glb")
newEl.setAttribute("position", ""+(Math.random()+this.xOffset)+" "+(Math.random()*this.scale+this.yOffset)+" "+(-Math.random()*this.scale+this.zOffset))
newEl.classList.add( generatorName )
el.appendChild(newEl)
}
},
events: {
reset: function (evt) {
console.log(this.attrName, 'component was resetted!');
this.correctlyPlacedFishes = 0
Array.from( document.querySelectorAll('.'+this.attrName) ).map( (e,i) => {
e.setAttribute("position", ""+(Math.random()+this.xOffset)+" "+(Math.random()*this.scale+this.yOffset)+" "+(-Math.random()*this.scale+this.zOffset))
})
},
check: function (evt) {
let generatorName = this.attrName
//used via onrelease="..."
if (!selectedElements || selectedElements.length < 1 ) {
console.warn(generatorName, 'check failed, should be called after entity moves, e.g onreleased="..."')
return // should only happen after something has been moved
}
let latest = selectedElements[selectedElements.length-1].element
let target = document.getElementById(generatorName+"_target")
let posA = new THREE.Vector3();
let posB = new THREE.Vector3();
latest.object3D.getWorldPosition( posA )
target.object3D.getWorldPosition( posB )
if ( posA.distanceTo( posB ) < .2 ) {
++this.correctlyPlacedFishes
console.log( this.correctlyPlacedFishes )
// forcing immovable
latest.removeAttribute("target") // doesn't unmount the component iirc, not sure it works though!
targets = targets.filter( e => e != target)
if ( this.correctlyPlacedFishes < 3 ) {
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_yes')
document.getElementById("biggucontinu").play()
}
if ( this.correctlyPlacedFishes == 3 ){
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_win')
document.getElementById("biggubravojulia").play()
}
}
}
}
})
//___________________________________________________________________________________________________________________________________
let correctlyPlacedLetters = 0
// should show the target or a box around the letter otherwise can be tricky to grab for some letters without a top left corner
// e.g B is easy, J is hard
AFRAME.registerComponent('letterstoword', {
init: function(){
correctlyPlacedLetters = 0
let generatorName = this.attrName
let word = "JULIA" // assumes 1 letter per word, should index position instead
const scale = 1/3.5
const xOffset = -.5
const yOffset = .5
const zOffset = -.1
let el = this.el
let whatever = [...word].map( (c,i) =>{
let newEl = document.createElement('a-text')
newEl.setAttribute("target", "")
newEl.setAttribute("value", c)
newEl.setAttribute("scale", ".5 .5 .5")
newEl.setAttribute("onreleased", "lettersCheckDistanceToDedicatedTargetSpot('"+generatorName+"')")
newEl.setAttribute("position", ""+(Math.random()+xOffset)+" "+(Math.random()*scale+yOffset)+" "+(-Math.random()*scale+zOffset))
newEl.classList.add( generatorName )
el.appendChild(newEl)
let targetEl = document.createElement('a-box')
targetEl.setAttribute("position", ""+(xOffset+i*scale)+" "+(yOffset)+" "+zOffset)
targetEl.id = generatorName+"_"+c
targetEl.setAttribute("scale", ".05 .05 .05")
targetEl.setAttribute("opacity", ".5")
el.appendChild(targetEl)
})
}
})
function lettersCheckDistanceToDedicatedTargetSpot(generatorName){
//used via onrelease="..."
let latest = selectedElements[selectedElements.length-1].element
let target = document.getElementById(generatorName+"_"+latest.getAttribute("value"))
// should also be params, getting complicated...
let posA = new THREE.Vector3();
let posB = new THREE.Vector3();
latest.object3D.getWorldPosition( posA )
target.object3D.getWorldPosition( posB )
if ( posA.distanceTo( posB ) < .2 ) {
latest.setAttribute("color", "green")
++correctlyPlacedLetters
console.log( correctlyPlacedLetters )
// forcing immovable
latest.removeAttribute("target") // doesn't unmount the component iirc, not sure it works though!
targets = targets.filter( e => e != target)
if ( correctlyPlacedLetters < 5 ) {
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_yes')
document.getElementById("biggucontinu").play()
}
if ( correctlyPlacedLetters == 5 ) {
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_win')
document.getElementById("biggubravojulia").play()
}
}
}
//___________________________________________________________________________________________________________________________________
let correctlyPlacedPrimitives = 0
AFRAME.registerComponent('table2entries', {
init: function(){
correctlyPlacedPrimitives = 0
let generatorName = this.attrName
// generate grid and models tool, with target positions to check against
const colors = ["red", "green", "blue"]
const primitive = ["box", "sphere", "cylinder"]
const scale = 1/3.5
const xOffset = .1
const yOffset = .2
const zOffset = -.6
let el = this.el
colors.map( (c,j) => {
let cel = document.createElement('a-plane')
cel.setAttribute("color", c)
cel.setAttribute("position", ""+(xOffset+colors.length*scale)+" "+(yOffset+j*scale)+" "+zOffset)
cel.setAttribute("scale", ".1 .1 .1")
el.appendChild(cel)
})
primitive.map( (p,i) => {
let pel = document.createElement('a-'+p)
pel.setAttribute("position", ""+(xOffset+i*scale)+" "+(yOffset+primitive.length*scale)+" "+zOffset)
pel.setAttribute("scale", ".05 .05 .05")
if (p=="box") pel.setAttribute("scale", ".1 .1 .1")
el.appendChild(pel)
colors.map( (c,j) => {
let newEl = document.createElement('a-'+p)
newEl.setAttribute("target", "")
newEl.setAttribute("color", c)
newEl.setAttribute("scale", ".05 .05 .05")
newEl.setAttribute("onreleased", "checkDistanceToDedicatedTargetSpot('"+generatorName+"')")
if (p=="box") newEl.setAttribute("scale", ".1 .1 .1")
newEl.setAttribute("position", ""+Math.random()+" "+Math.random()+" "+(Math.random()+zOffset))
newEl.classList.add( generatorName )
el.appendChild(newEl)
let targetEl = document.createElement('a-box')
targetEl.setAttribute("position", ""+(xOffset+i*scale)+" "+(yOffset+j*scale)+" "+zOffset)
targetEl.id = generatorName+"_"+p+"_"+c
targetEl.setAttribute("scale", ".05 .05 .05")
targetEl.setAttribute("opacity", ".5")
el.appendChild(targetEl)
})
})
}
})
function checkDistanceToDedicatedTargetSpot(generatorName){
//used via onrelease="..."
let latest = selectedElements[selectedElements.length-1].element
let target = document.getElementById(generatorName+"_"+latest.localName.split('-')[1]+"_"+latest.getAttribute("color"))
// should also be params, getting complicated...
let posA = new THREE.Vector3();
let posB = new THREE.Vector3();
latest.object3D.getWorldPosition( posA )
target.object3D.getWorldPosition( posB )
let idCheck = generatorName+"_"+latest.localName.split('-')[1]+"_"+latest.getAttribute("color")
// should also be params, getting complicated...
console.log (idCheck, posA.distanceTo( posB ), posA.distanceTo( posB ) < .2 )
if ( posA.distanceTo( posB ) < .2 ) {
latest.setAttribute("wireframe", true)
++correctlyPlacedPrimitives
console.log( correctlyPlacedPrimitives )
// forcing immovable
latest.removeAttribute("target") // doesn't unmount the component iirc, not sure it works though!
targets = targets.filter( e => e != target)
if ( correctlyPlacedPrimitives < 9 ) {
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_yes')
document.getElementById("biggucontinu").play()
}
if ( correctlyPlacedPrimitives == 9 ) {
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_win')
document.getElementById("biggubravojulia").play()
}
}
}
//___________________________________________________________________________________________________________________________________
// should moved to e.g src='jxr-game-maze.js'
AFRAME.registerComponent('mazemap', {
init: function(){
let el = this.el
this.data.split("\n").filter(l=>l.length>0).map( (line,i) => {
let whatever = [...line].map( (c,j) =>{
let newEl = document.createElement("a-box")
let color
switch (c){
case "1":
color="blue"
newEl.setAttribute("height", 2)
break;
case "0":
color="white"
newEl.setAttribute("material", "metalness:.2") // no big difference
break;
case "S":
color="grey"
break;
case "E":
color="grey"
newEl.id = "mazeend"
break;
}
newEl.setAttribute("color", color)
newEl.setAttribute("position", ""+j+" 0 "+i)
el.appendChild(newEl)
})
})
}
})
// could also get from parameter URL e.g mazemap=S1111,00001,10111,10001,1110E as suggested by Leon
function forbiddenSpots(){
// should only be done once
return Array.from( document.querySelectorAll("#maze>a-entity>a-box[color=blue]") )
.map( el => { let pos = new THREE.Vector3(); el.object3D.getWorldPosition(pos); return pos})
}
function overForbiddenSpot(selectorA="#biggu", distanceThreshold=.2){
let posA = new THREE.Vector3();
document.querySelector(selectorA).object3D.getWorldPosition( posA )
let over = false
forbiddenSpots().map( posB => {
if ( posA.distanceTo( posB ) < distanceThreshold )
over = true
})
return over
}
function moveBigguForward(step=.2){
/* // move with waddle example
let biggu = document.querySelector("#biggu")
biggu.setAttribute("animation__translation", "property: position; to: 0 0 0.5; dur: 10000;")
biggu.setAttribute("animation__waddle", "property: rotation; from: 0 -20 -10; to: 0 20 10; dur: 1000; loop:true; easing: linear; dir:alternate;")
*/
// could also first if within maze boundaries
document.querySelector("#biggu").object3D.translateZ(step)
if (overForbiddenSpot())
setTimeout( _ => document.querySelector("#biggu").object3D.translateZ(-step), 500 )
}
function moveBigguBackward(step=-.2){
// could also first if within maze boundaries
document.querySelector("#biggu").object3D.translateZ(step)
if (overForbiddenSpot())
setTimeout( _ => document.querySelector("#biggu").object3D.translateZ(-step), 500 )
}
function moveBigguRight(step=.2){
// could also first if within maze boundaries
document.querySelector("#biggu").object3D.translateX(step)
if (overForbiddenSpot())
setTimeout( _ => document.querySelector("#biggu").object3D.translateX(-step), 500 )
}
function moveBigguLeft(step=-.2){
// could also first if within maze boundaries
document.querySelector("#biggu").object3D.translateX(step)
if (overForbiddenSpot())
setTimeout( _ => document.querySelector("#biggu").object3D.translateX(-step), 500 )
}
function checkWinCondition(selectorA="#biggu", selectorB="#mazeend", distanceThreshold=.2){
let posA = new THREE.Vector3();
let posB = new THREE.Vector3();
document.querySelector(selectorA).object3D.getWorldPosition( posA )
document.querySelector(selectorB).object3D.getWorldPosition( posB )
if ( posA.distanceTo( posB ) < distanceThreshold ) {
animateThenIdle( document.querySelector("#biggu"), 'bigguaction_win')
document.getElementById("biggubravojulia").play()
}
return ( posA.distanceTo( posB ) < distanceThreshold )
}
function animateThenIdle(mainCharacter, animationName, timeScale='1'){
mainCharacter.setAttribute('animation-mixer', "clip:"+animationName+";loop:once; timeScale:"+timeScale)
mainCharacter.addEventListener('animation-finished', _ => {
mainCharacter.setAttribute('animation-mixer', "clip:bigguaction_idle; loop:true;")
})
// could return the animation duration or an event when done
}
// end src='jxr-game-maze.js'
//___________________________________________________________________________________________________________________________________
function doesThisContainThat(latest, nearby){
//let latest = selectedElements[selectedElements.length-1].element
//let nearby = getClosestTargetElements( latest.getAttribute('position') )
// https://threejs.org/docs/?q=box#api/en/math/Box3.containsBox
// https://threejs.org/docs/?q=box#api/en/math/Box3.expandByObject
let a = new THREE.Box3().expandByObject( latest.object3D ) // consider mesh.geometry.computeBoundingBox() first
let b = new THREE.Box3().expandByObject( nearby.object3D )
return a.containsBox(b)
// testable as doesThisContainThat( document.querySelector("[color='yellow']"), document.querySelector("[color='purple']") )
// < a-box scale = ".1 .1 .1" position = ".5 .8 -.3" color = "purple" > < / a-box >
// < a-box scale = ".2 .2 .2" position = ".5 .8 -.3" color = "yellow" > < / a-box >
}
function snapToGrid(gridSize=1){ // default as 1 decimeter
let latest = selectedElements[selectedElements.length-1].element
latest.setAttribute("rotation", "0 0 0")
let pos = latest.getAttribute("position")
pos.multiplyScalar(gridSize*10).round().divideScalar(gridSize*10)
latest.setAttribute("position", pos )
}
// deeper question, making the rules themselves manipulable? JXR?
// So the result of the grammar becomes manipulable, but could you make the rules of the grammar itself visual? Even manipulable?
// could start by visualizing examples first e.g https://writer.com/wp-content/uploads/2024/03/grammar-1.webp
function snapMAB(){
// multibase arithmetic blocks aka MAB cf https://en.wikipedia.org/wiki/Base_ten_block
let latest = selectedElements[selectedElements.length-1].element
let nearby = getClosestTargetElements( latest.getAttribute('position') )
let linked = []
if (nearby.length>0){
latest.setAttribute("rotation", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("rotation") ) )
latest.setAttribute("position", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("position") ) )
latest.object3D.translateX( 1/10 )
linked.push( latest )
linked.push( nearby[0].el )
let overlap = Array.from( document.querySelectorAll(".mab") ).filter( e => e.object3D.position.distanceTo( latest.object3D.position ) < 0.01 & & e ! = latest )
while (overlap.length > 0 ){
latest.object3D.translateX( 1/10 )
linked.push( overlap[0] )
overlap = Array.from( document.querySelectorAll(".mab") ).filter( e => e.object3D.position.distanceTo( latest.object3D.position ) < 0.01 & & e ! = latest )
}
// do something special if it becomes 10, e.g become a single line, removing the "ridges"
if (linked.length > 3)
linked.map( e => Array.from( e.querySelectorAll("a-box") ).setAttribute("color", "orange") )
// also need to go backward too to see if it's the latest added
}
}
function snapRightOf(){
let latest = selectedElements[selectedElements.length-1].element
let nearby = getClosestTargetElements( latest.getAttribute('position') )
if (nearby.length>0){
latest.setAttribute("rotation", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("rotation") ) )
latest.setAttribute("position", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("position") ) )
latest.object3D.translateX( 1/10 )
// somehow... works only the 2nd time, not the 1st?!
}
}
function grammarBasedSnap(){
// verify if snappable, e.g of same type (or not)
// e.g check if both have .getAttribute('value').match(prefix) or not
let latest = selectedElements[selectedElements.length-1].element
let nearby = getClosestTargetElements( latest.getAttribute('position') )
if (nearby.length>0){
let closest = nearby[0].el
let latestTypeJXR = latest.getAttribute('value').match(prefix)
let closestTypeJXR = latest.getAttribute('value').match(prefix)
latest.setAttribute("rotation", AFRAME.utils.coordinates.stringify( closest.getAttribute("rotation") ) )
latest.setAttribute("position", AFRAME.utils.coordinates.stringify( closest.getAttribute("position") ) )
if ( latestTypeJXR & & closestTypeJXR )
latest.object3D.translateX( 1/10 ) // same JXR type, snap close
else
latest.object3D.translateX( 2/10 ) // different types, snap away
// somehow... works only the 2nd time, not the 1st?!
}
}
function cloneTarget(target){
let el = target.cloneNode(true)
if (!el.id)
el.id = "clone_" + crypto.randomUUID()
else
el.id += "_clone_" + crypto.randomUUID()
AFRAME.scenes[0].appendChild(el)
}
function deleteTarget(target){
targets = targets.filter( e => e != target)
target.remove()
}
function runClosestJXR(){
// ideally this would come from event details
let latest = selectedElements[selectedElements.length-1].element
let nearby = getClosestTargetElements( latest.getAttribute('position') )
// if (nearby.length>0){ interpretJXR( nearby[0].el.getAttribute("value") ) }
nearby.map( n => interpretJXR( n.el.getAttribute("value") ) )
}
AFRAME.registerComponent('idleafterload', {
events: {
'model-loaded': function (evt) {
console.log('This entity was loaded!');
this.el.setAttribute('animation-mixer', "clip:bigguaction_idle; loop:true;")
}
}
});
< / script >
< div style = 'position:fixed;z-index:1; top: 0%; left: 0%; border-bottom: 70px solid transparent; border-left: 70px solid #eee; ' >
< a href = "https://git.benetou.fr/utopiah/text-code-xr-engine/issues/" >
< img style = 'position:fixed;left:10px;' title = 'code repository' src = 'gitea_logo.svg' >
< / a >
< / div >
< a-scene startfunctions >
< a-assets >
< audio id = "biggucestmoi" src = "../content/voicesBigguJulia/biggu-fem.mp3" > < / audio >
< audio id = "biggubravojulia" src = "../content/voicesBigguJulia/bravojulia.mp3" > < / audio >
< audio id = "biggucontinu" src = "../content/voicesBigguJulia/continu.mp3" > < / audio >
< audio id = "bigguinstructions" src = "../content/voicesBigguJulia/instructions.mp3" > < / audio >
< template id = "avatar-template" > < / template >
< template id = "left-hand-default-template" >
< a-entity networked-hand-controls = "hand:left" > < / a-entity >
< / template >
< template id = "right-hand-default-template" >
< a-entity networked-hand-controls = "hand:right" > < / a-entity >
< / template >
< / a-assets >
< a-gltf-model id = "environment" hide-on-enter-ar = "" src = "../content/winterset/WinterIsland.glb" rotation = "0 20 0" position = "2 -4.5 -3" > < / a-gltf-model >
< a-gltf-model src = "../content/winterset/Crystal_iPoly3D.glb" position = "-0.4 -0.2 -3" scale = "0.1 0.1 0.1" > < / a-gltf-model >
< a-gltf-model idleafterload id = "biggu" src = "../content/winterset/SK_Biggu_v029_optimized.glb" position = "0 0 -1" >
<!-- <a - sound src="#bigguinstructions"></a - sound> -->
< / a-gltf-model >
< a-entity id = "rig" >
< a-entity id = "player" networked = "template:#avatar-template;attachTemplateToLocal:false;"
hud camera look-controls wasd-controls position="0 1.6 0">
< / a-entity >
< a-entity id = "rightHand" pinchprimary hand-tracking-controls = "hand: right;" > < / a-entity >
< a-entity id = "leftHand" pinchsecondary wristattachsecondary = "target: #box" hand-tracking-controls = "hand: left;" > < / a-entity >
< / a-entity >
< a-troika-text value = "SpaSca : Spatial Scaffolding" anchor = "left" outline-width = "5%" font = "../content/ChakraPetch-Regular.ttf" position = "-3 5 -2"
scale="3 3 3" rotation="80 0 0" troika-text="outlineWidth: 0.01; strokeColor: #ffffff" material="flatShading: true; blending: additive; emissive: #c061cb">< / a-troika-text >
< a-sky hide-on-enter-ar color = "lightgray" > < / a-sky >
< a-troika-text anchor = left target value = "instructions : \n--right pinch to move\n--left pinch to execute" position = "0 1.65 -0.2" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-troika-text anchor = left value = "jxr location.reload()" target position = "-.5 1.30 0" rotation = "0 40 0" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-troika-text anchor = left value = "jxr window.location.hash = ''" target position = "-.5 1.40 0" rotation = "0 40 0" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-console position = "2 2 0" rotation = "0 -45 0" font-size = "34" height = 1 skip-intro = true > < / a-console >
<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -->
< a-entity visible = "false" id = "maze" class = "game" onstart = "" wincondition = "checkWinCondition()" losecondition = "" advice = "" onmistake = "" >
< a-entity scale = "0.2 0.2 0.2" position = "0 -.1 -1" mazemap = "
S1111
00001
10111
10001
1110E">
< / a-entity >
< a-gltf-model class = "bigguEndPoint" scale = "0.002 0.002 0.002" position = ".9 0.1 -.2" gltf-model = "../content/winterset/Fish.glb" > < / a-gltf-model >
<!-- bad offset... so leaving the end position as part of the maze itself -->
< a-troika-text anchor = left value = "jxr moveBigguForward(); checkWinCondition()" target position = "-0.3 .60 -.3" rotation = "90 0 0" annotation = "content: BIGGU DEVANT" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-troika-text anchor = left value = "jxr moveBigguBackward(); checkWinCondition()" target position = "-0.3 .40 -.3" rotation = "90 0 0" annotation = "content: BIGGU DERRIERE" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-troika-text anchor = left value = "jxr moveBigguRight(); checkWinCondition()" target position = "-0.1 .50 -.3" rotation = "90 0 0" annotation = "content: BIGGU DROITE" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-troika-text anchor = left value = "jxr moveBigguLeft(); checkWinCondition()" target position = "-0.5 .50 -.3" rotation = "90 0 0" annotation = "content: BIGGU GAUCHE" scale = "0.1 0.1 0.1" > < / a-troika-text >
< / a-entity >
<!-- restart? location.reload() for now -->
<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -->
< a-entity visible = "false" id = "table2entries" class = "game" onstart = "" wincondition = "" losecondition = "" advice = "" onmistake = "" table2entries > < / a-entity >
<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -->
< a-entity visible = "false" id = "letterstoword" class = "game" onstart = "" wincondition = "" losecondition = "" advice = "" onmistake = "" letterstoword > < / a-entity >
<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -->
< a-entity visible = "false" id = "fishinbowl" class = "game" onstart = "" wincondition = "" losecondition = "" advice = "" onmistake = "" fishinbowl >
< a-gltf-model id = "fishinbowl_target" src = "../content/winterset/FruitBowl.glb" position = "0.00055 -0.01343 -0.4778" scale = "0.1 0.1 0.1" > < / a-gltf-model >
< / a-entity >
<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -->
< a-box id = "box" visible = "false" > < / a-box >
<!-- bug if #box missing, so hiding for now -->
<!-- bug in start - on - press after XR init, as mentioned there -->
<!-- testing on physics to toss/object objects -->
<!-- does not work somehow at that position but - .5! -->
<!--
< a-sphere target throw-on-drop radius = ".05" position = "0 1.5 -.2" color = "orange" > < / a-sphere >
< a-troika-text throw-on-drop value = "movable" target position = "0 1.30 -.5" scale = "0.1 0.1 0.1" > < / a-troika-text >
< a-plane position = "0 1.1 -.5" rotation = "-90 0 0" static-body > < / a-plane >
-->
< / a-scene >
< / body >
< / script >
< / html >