Last active
November 23, 2017 01:20
-
-
Save greaneym/fad9cbdb56af9d9b48467d22a7e587e0 to your computer and use it in GitHub Desktop.
rogue react game for FCC zipline
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.button { | |
position: absolute; | |
left: 20px; | |
margin: 5px; | |
top: 5px; | |
background-color: #f9fcfd; | |
height: 45px; | |
width: 55px; | |
font-family: normal 20px/1.5 "Open Sans", sans-serif; | |
font-color: "black"; } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use strict"; | |
/* | |
used Hexi (pixi) to build a simple game prototype | |
used RinCon web log for example of react container for pixi | |
*/ | |
/* future enhancements, | |
. make messages DRY with function | |
. figure out how to solve CORS problem when loading remote assets | |
like tilesets and sounds | |
. add more react.js Reset button and container are React.js rest is pixi.js | |
. add more rooms and levels, just one level here for prototype | |
*/ | |
var g,score; | |
//Create a new Hexi instance, and start it | |
// this was started in html | |
// not all assets work yet. | |
/* | |
//var chimeSound = "/hexi/hexi-master/tutorials/sounds/chimes.wav"; | |
// g = hexi(512, 512, setup); | |
//Set the background color and scale the canvas | |
g.backgroundColor = "white"; | |
g.scaleToWindow(); | |
//Start Hexi | |
g.start(); | |
count = 0; | |
*/ | |
// create something to be masked.. | |
//var mySprite = PIXI.Sprite.fromImage("testImage.png"); | |
// this takes the place of rogue-like fov where mask is on top | |
// of cursor and blocks vision but still can see things if move around | |
var myS1 = PIXI.Sprite.fromImage("https://gist.githubusercontent.com/greaneym/15d8e91b19474f03fc6e2da00c567c5f/raw/630ad277ebbf08892416bf53bfde9e88ad45758d/perlinsnap.png"); | |
myS1.width = 400; | |
myS1.height = 400; | |
myS1.x = 50; | |
myS1.y = 50; | |
//Declare your global variables (global to this game, which means you | |
//want to use them in more than one function) | |
// health = obtaining treasure and finding the door | |
// XP gained with exiting door and/or killing the boss | |
// The boss can kill player if unarmed and collision occurs. | |
// Player's health goes down if collides with enemies. | |
// Player can kill boss if obtains sticks. | |
// sticks have sidedness, one side does not kill. | |
// Player must hit boss multiple (4x) times to reduce alpha (kill) the boss. | |
// Player must have health and XP to win, score can be negative. | |
var dungeon = undefined, | |
player = undefined, | |
boss = undefined, | |
treasure = undefined, | |
enemies = undefined, | |
stick1 = undefined, | |
stick2= undefined, | |
chimes = undefined, | |
exit = undefined, | |
healthBar = undefined, | |
message = undefined, | |
message2 = undefined, | |
message3 = undefined, | |
message4 = undefined, | |
message5 = undefined, | |
message6 = undefined, | |
gameScene = undefined, | |
outerCover = undefined, | |
gameMiddleScene = undefined, | |
gameMiddleScene2 = undefined, | |
gameOverScene = undefined, | |
gameXPScene = undefined, | |
score = undefined, | |
xp = undefined, | |
scoreDisplay = undefined, | |
scoreDisplayTitle = undefined, | |
xpDisplay = undefined, | |
xpDisplayTitle = undefined, | |
scoreNeededToWin = undefined, | |
xpNeededToWin = undefined, | |
gameBossScene = undefined, | |
gamePlayerScene = undefined, | |
darkbulb = undefined; | |
//The `setup` function runs once and is used to initializes your game | |
function setup() { | |
//Create the `chimes` sound object | |
// chimes = g.sound("chimeSound"); | |
//Create the `gameScene` group | |
gameScene = g.group(); | |
// gameScene.addChild(myMask); | |
// gameScene.addChild(lighting); | |
function randomRange(myMin, myMax) { | |
var result = Math.floor(Math.random() * (myMax - myMin + 1)) + myMin; | |
return result; // Change this line | |
} | |
//The exit door | |
exit = g.rectangle(48, 48, "green"); | |
//not moving this very much but you could change the values for more | |
//randomness in location | |
exit.x = 22 + randomRange(2,3); | |
exit.y = 22 - randomRange(1,4); | |
console.log("exit.xy", exit.x,exit.y); | |
gameScene.addChild(exit); | |
//The player sprite | |
// The player moves with the cursor | |
player = g.rectangle(32, 32, "blue"); | |
player.x = 68; | |
player.y = g.canvas.height / 2 - player.halfHeight; | |
gameScene.addChild(player); | |
var covering = new PIXI.Graphics(); | |
var rr = Math.random() * 0x10 | 0; | |
var rg = Math.random() * 0x10 | 0; | |
var rb = Math.random() * 0x10 | 0; | |
var rad = 80 + Math.random() * 20; | |
covering.beginFill((rr << 0) + (rg << 0) + rb, 10); | |
covering.drawRect(0, 0, 200,200); | |
covering.endFill(); | |
var darkbulb = new PIXI.Graphics(); | |
var rr = 0x99999; | |
var rg = 0x99999; | |
var rb = 0x99999; | |
var rad = 180 + Math.random() * 20; | |
darkbulb.beginFill((rr ) + (rg) + rb, 0.7); | |
darkbulb.drawCircle(0, 0, rad); | |
darkbulb.endFill(); | |
//The boss sprite | |
boss = g.rectangle(32, 52, "black"); | |
g.stage.putCenter(boss, (28 + randomRange(1,20)), (100 + randomRange(1,20))); | |
//boss.x = 28 + randomRange(1,5); | |
//boss.y = 100 + randomRange(-3, 3); | |
boss.alpha = 900; //if hit, this goes down and color changes | |
gameScene.addChild(boss); | |
// console.log("boss", boss); | |
//Create the score message | |
//`text` arguments: stringContent, font, color, x, y. | |
scoreDisplayTitle = g.text("Score: ", "20px helvetica", "#00FF00", 375, 30); | |
scoreDisplay = g.text(" ", "20px helvetica", "#00FF00", 455, 30); | |
xpDisplayTitle = g.text("XP: ", "20px helvetica", "#00FF00", 395, 55); | |
xpDisplay = g.text(" ", "20px helvetica", "#00FF00", 455, 55); | |
//Game variables | |
score = 0; | |
scoreNeededToWin = 80; | |
xp = 0; | |
xpNeededToWin = 2000; | |
//Create the treasure | |
//w, h, color | |
treasure = g.rectangle(16, 16, "gold"); | |
//Create the stick weapons | |
//w, h, color | |
stick1 = g.rectangle(34, 8, "orange"); // this stick has to be pushed | |
// below to move it | |
stick2 = g.rectangle(34, 10, "purple"); | |
/* | |
// Here are two ways to move things around but here used put.center | |
//Position it next to the left edge of the canvas | |
treasure.x = 20 * Math.random(2); | |
treasure.y = 10; // + 6* Math.random(); | |
//Position it next to the left edge of the canvas | |
stick1.x = 400 + (10 * Math.random()); | |
stick1.y = 400 - (10 * Math.random()); | |
stick2.x = g.canvas.width - stick2.width - (6 * Math.random()); | |
stick2.y = g.canvas.height / (2 * Math.random()) - stick2.halfHeight; | |
*/ | |
//Alternatively, you could use Ga's built in convenience method | |
//called `putCenter` to postion the sprite like this: | |
// I purposely made the calculations so that they are random but | |
// still in relatively the same starting position. Use this | |
// to make a bigger variety of locations | |
g.stage.putCenter(treasure, (208 + randomRange(1,3)), (10 + randomRange(1,2))); | |
g.stage.putCenter(stick1, (228 + randomRange(1,5)), (30 + randomRange(1,15))); | |
g.stage.putCenter(stick2, (228 + randomRange(0,7)), (26 + randomRange(1,20))); | |
//Create a `pickedUp` property on the treasure to help us Figure | |
//out whether or not the treasure has been picked up by the player | |
treasure.pickedUp = false; | |
stick1.pickedUp = false; | |
stick2.pickedUp = false; | |
//Add the treasure to the `gameScene` | |
gameScene.addChild(treasure); | |
gameScene.addChild(stick1); | |
gameScene.addChild(stick2); | |
//Make the enemies | |
var numberOfEnemies = 6, | |
spacing = 48, | |
xOffset = 150, | |
speed = 2, | |
direction = 1; | |
//An array to store all the enemies | |
enemies = []; | |
//Make as many enemies as there are `numberOfEnemies` | |
for (var i = 0; i < numberOfEnemies; i++) { | |
//Each enemy is a red rectangle | |
var enemy = g.rectangle(32, 32, "red"); | |
//Space each enemey horizontally according to the `spacing` value. | |
//`xOffset` determines the point from the left of the screen | |
//at which the first enemy should be added. | |
var x = spacing * i + xOffset; | |
//Give the enemy a random y position | |
var y = g.randomInt(0, g.canvas.height - enemy.height); | |
//Set the enemy's direction | |
enemy.x = x; | |
enemy.y = y; | |
//Set the enemy's vertical velocity. `direction` will be either `1` or | |
//`-1`. `1` means the enemy will move down and `-1` means the enemy will | |
//move up. Multiplying `direction` by `speed` determines the enemy's | |
//vertical direction | |
enemy.vy = speed * direction; | |
//Reverse the direction for the next enemy | |
direction *= -1; | |
//Push the enemy into the `enemies` array | |
enemies.push(enemy); | |
//Add the enemy to the `gameScene` | |
gameScene.addChild(enemy); | |
} | |
//Create the health bar | |
var outerBar = g.rectangle(128, 16, "black"), | |
innerBar = g.rectangle(128, 16, "yellowGreen"); | |
//Group the inner and outer bars | |
healthBar = g.group(outerBar, innerBar); | |
//Set the `innerBar` as a property of the `healthBar` | |
healthBar.inner = innerBar; | |
//Position the health bar | |
healthBar.x = g.canvas.width - 148; | |
healthBar.y = 16; | |
//Add the health bar to the `gameScene` | |
gameScene.addChild(healthBar); | |
//Add some text for the game over message | |
message = g.text("Game Over!", "64px Futura", "black", 20, 20); | |
message.x = 120; | |
message.y = g.canvas.height / 2 - 64; | |
//Add some sidetext for the capture treasure message | |
message2 = g.text("Won Treasure!", "20px Futura", "black", 20, 20); | |
message2.x = 20; | |
message2.y = g.canvas.height / 2 - 64; | |
//Add some sidetext for the stick message | |
message3 = g.text("Enemies Fear the Stick!", "20px Futura", "black", 21, 23); | |
//Add some sidetext for the boss message | |
message4 = g.text("You killed the Boss!", "20px Futura", "black", 20, 23); | |
message5 = g.text("The Boss killed You!", "20px Futura", "red", 20, 35); | |
message6 = g.text("XP Treasure for You!", "20px Futura", "black", 24, 40); | |
//Create a `gameMiddleScene` group and add the message sprite to it | |
gameMiddleScene = g.group(message2); | |
gameMiddleScene.visible = false; | |
gameMiddleScene2 = g.group(message3); | |
gameMiddleScene2.visible = false; | |
gameBossScene = g.group(message4); | |
gameBossScene.visible = false; | |
gamePlayerScene = g.group(message5); | |
gamePlayerScene.visible = false; | |
gameXPScene = g.group(message6); | |
gameXPScene.visible = false; | |
gameOverScene = g.group(message); | |
//Make the `gameOverScene` invisible for now | |
gameOverScene.visible = false; | |
gameScene.addChild(myS1); | |
myS1.mask = darkbulb; // has interesting effect | |
player.addChild(darkbulb); | |
// near the end | |
//Let the user control the player character using the keyboard. | |
//Hexi's `arrowControl` method lets you do this easily. Supply the | |
//sprite you want to move as the first argument, and the number of | |
//pixels per frame that it should move as the second argument | |
// g.arrowControl(player, 5); | |
//The `arrowControl` method is great for prototyping a game | |
//but for more flexibility you can also program the arrow keys | |
//manually, like this: | |
//Create some keyboard objects using Hexi's `keyboard` method. | |
//You would usually use this code in the `setup` function | |
//Supply the ASCII key code value as the single argument | |
let leftArrow = g.keyboard(37), | |
upArrow = g.keyboard(38), | |
rightArrow = g.keyboard(39), | |
downArrow = g.keyboard(40); | |
//Left arrow key `press` method | |
leftArrow.press = () => { | |
//Change the player's velocity when the key is pressed | |
player.vx = -5; | |
player.vy = 0; | |
}; | |
//Left arrow key `release` method | |
leftArrow.release = () => { | |
//If the left arrow has been released, and the right arrow isn't down, | |
//and the player isn't moving vertically: | |
//Stop the player | |
if (!rightArrow.isDown && player.vy === 0) { | |
player.vx = 0; | |
} | |
}; | |
upArrow.press = () => { | |
player.vy = -5; | |
player.vx = 0; | |
}; | |
upArrow.release = () => { | |
if (!downArrow.isDown && player.vx === 0) { | |
player.vy = 0; | |
} | |
}; | |
rightArrow.press = () => { | |
player.vx = 5; | |
player.vy = 0; | |
}; | |
rightArrow.release = () => { | |
if (!leftArrow.isDown && player.vy === 0) { | |
player.vx = 0; | |
} | |
}; | |
downArrow.press = () => { | |
player.vy = 5; | |
player.vx = 0; | |
}; | |
downArrow.release = () => { | |
if (!upArrow.isDown && player.vx === 0) { | |
player.vy = 0; | |
} | |
}; | |
//set the game state to `play` | |
g.state = play; | |
} | |
g = hexi(512, 512, setup); | |
//Set the background color and scale the canvas | |
g.backgroundColor = "white"; | |
g.scaleToWindow(); | |
//Start Hexi | |
g.start(); | |
//The `play` function contains all the game logic and runs in a loop | |
function play() { | |
//Move the player | |
g.move(player); | |
//Keep the player contained inside the stage's area | |
g.contain(player, g.stage); | |
//Move the enemies and check for a collision | |
//Set `playerHit` to `false` before checking for a collision | |
var playerHit = false; | |
var stick1Hit = false; | |
var stick2Hit = false; | |
var bossHit = false; | |
//Loop through all the sprites in the `enemies` array | |
enemies.forEach(function (enemy) { | |
//Move the enemy | |
g.move(enemy); | |
//Check the enemy's screen boundaries | |
var enemyHitsEdges = g.contain(enemy, g.stage); | |
//If the enemy hits the top or bottom of the stage, reverse | |
//its direction | |
if (enemyHitsEdges) { | |
if (enemyHitsEdges.has("top") || enemyHitsEdges.has("bottom")) { | |
enemy.vy *= -1; | |
} | |
} | |
//Test for a collision. If any of the enemies are touching | |
//the player, set `playerHit` to `true` | |
if (g.hitTestRectangle(player, enemy)) { | |
playerHit = true; | |
//console.log("playerHit?", playerHit); | |
} | |
//Test for a collision w weapon. If any of the enemies are touching | |
//the player, set `stickHit` to `true` | |
if (g.hitTestRectangle(stick1, enemy)) { | |
stick1Hit = true; | |
score += 2; | |
} | |
if (g.hitTestRectangle(stick2, enemy)) { | |
stick2Hit = true; | |
xp +=10; | |
enemy.alpha -=1; | |
} | |
if (g.hitTestRectangle(stick2, boss)) { | |
bossHit = true; | |
xp +=20; | |
boss.alpha = boss.alpha -1; | |
if (boss.alpha < 0) { | |
gameBossScene.visible = true; | |
g.wait(1000, function () { | |
return (gameBossScene.visible = false) | |
}); | |
if (xp > 2000) { xp = 2000; } //why is xp going over 2000? | |
g.wait(2000, function () { | |
return (g.state = end) | |
}); | |
} | |
} | |
}); | |
//If the player is hit... | |
if (playerHit) { | |
//Make the player semi-transparent | |
player.alpha = 0.5; | |
score -= 3; | |
//Reduce the width of the health bar's inner rectangle by 1 pixel | |
healthBar.inner.width -= 1; | |
} else { | |
//Make the player fully opaque (non-transparent) if it hasn't been hit | |
player.alpha = 1; | |
} | |
if ((stick1Hit) || (stick2Hit)){ | |
//Make the player semi-transparent | |
player.alpha = 1; | |
//Reduce the width of the health bar's inner rectangle by 1 pixel | |
healthBar.inner.width += 1; | |
} | |
//Check for a collision between the player and the treasure | |
if (g.hitTestRectangle(player, treasure)) { | |
//If the treasure is touching the player, center it over the player | |
treasure.x = player.x + 8; | |
treasure.y = player.y + 8; | |
if (!treasure.pickedUp) { | |
//If the treasure hasn't already been picked up, | |
treasure.pickedUp = true; | |
gameMiddleScene.visible = true; | |
//Wait for 1 second (1000 milliseconds) then | |
//remove the message | |
g.wait(1000, function () { | |
return (gameMiddleScene.visible = false) | |
}); | |
}; | |
} | |
//Check for a collision between the player and the stick weapon | |
if (g.hitTestRectangle(player, stick1)) { | |
//If the stick is touching the player, center it over the player | |
stick1.x = player.x - 5; | |
stick1.y = player.y - 5; | |
if (!stick1.pickedUp) { | |
stick1.pickedUp = true; | |
gameMiddleScene2.visible = true; | |
//Wait for 1 second (1000 milliseconds) then | |
//remove the message | |
g.wait(1000, function () { | |
return (gameMiddleScene2.visible = false) | |
}); | |
}; | |
} | |
//Check for a collision between the player and the stick weapon | |
if (g.hitTestRectangle(player, stick2)) { | |
//If the stick is touching the player, center it over the player | |
stick2.x = player.x + 6; | |
stick2.y = player.y + 6; | |
if (!stick2.pickedUp) { | |
stick2.pickedUp = true; | |
gameMiddleScene2.visible = true; | |
//Wait for 1 second (1000 milliseconds) then | |
//remove the message | |
g.wait(1000, function () { | |
return (gameMiddleScene2.visible = false) | |
}); | |
}; | |
} | |
if (g.hitTestRectangle(player, boss)) { | |
//If the player hits boss without stick,game over | |
if (!stick2.pickedUp) { | |
gamePlayerScene.visible = true; | |
//Wait for 1 second (1000 milliseconds) then | |
//remove the message | |
g.wait(1000, function () { | |
return (gamePlayerScene.visible = false) | |
}); | |
g.wait(2000, function () { | |
return (g.state = end) | |
}); | |
}; | |
}; | |
/* Display the score */ | |
scoreDisplay.content = score; | |
xpDisplay.content = xp; | |
//Check for the end of the game | |
//Does the player have enough health? If the width of the `innerBar` | |
//is less than zero, end the game and display "You lost!" | |
if (healthBar.inner.width < 0) { | |
g.state = end; | |
message.content = "You lost!"; | |
} | |
//If the player has brought the treasure to the exit, | |
//you get more XP!" | |
if (g.hitTestRectangle(treasure, exit) || (score === scoreNeededToWin)) { | |
xp += 50; | |
gameXPScene.visible = true; | |
g.wait(1000, function () { | |
return (gameXPScene.visible = false) | |
}); | |
} | |
//The player wins if the score matches the value | |
//of `scoreNeededToWin`, which is 60 | |
if (score === scoreNeededToWin) { | |
gameOverScene.visible = true; | |
g.wait(1000, function () { | |
return (ogameXPScene.visible = false) | |
}); | |
g.state = end; | |
} | |
if (xp === xpNeededToWin) { | |
gameXPScene.visible = true; | |
g.wait(1000, function () { | |
return (gameXPScene.visible = false) | |
}); | |
g.state = end; | |
} | |
} //end play don't remove | |
function end() { | |
//Hide the `gameScene` and display the `gameOverScene` | |
gameScene.visible = false; | |
gameOverScene.visible = true; | |
} | |
function reset() { | |
//remove assets | |
g.remove(enemies); | |
g.remove(stick1); | |
g.remove(stick2); | |
g.remove(treasure); | |
g.remove(healthBar); | |
g.remove(message); | |
g.remove(message2); | |
g.remove(message3); | |
g.remove(message4); | |
g.remove(message5); | |
g.remove(message6); | |
g.remove(exit); | |
g.remove(player); | |
g.remove(boss); | |
g.remove(scoreDisplay); | |
g.remove(xpDisplay); | |
//score = 0; | |
//xp = 0; | |
gameScene.visible = true; | |
gameOverScene.visible = false; | |
setup(); | |
//this.renderer.render(stage); | |
scoreDisplay = g.text(" ", "20px helvetica", "#00FF00", 470, 30); | |
xpDisplay = g.text(" ", "20px helvetica", "#00FF00", 470, 55); | |
g.state = play; | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Canvas extends React.Component { | |
constructor( props ) { | |
super(props); | |
//bind our animate function | |
this.animate = this.animate.bind(this); | |
} | |
/** | |
* Animation loop for updating Pixi Canvas | |
**/ | |
animate() { | |
this.frame = requestAnimationFrame(this.animate); | |
} | |
componentDidMount() { | |
var g = hexi(512, 512, setup); | |
} | |
/** | |
* shouldComponentUpdate is used to check our new props against the current | |
* and only update if needed | |
**/ | |
shouldComponentUpdate(nextProps, nextState) { | |
console.log("no props"); | |
} | |
/** | |
* When we get new props, run the appropriate imperative functions | |
**/ | |
componentWillReceiveProps(nextProps) { | |
console.log("no props"); | |
} | |
updateCount(props) { | |
console.log("no props updated"); | |
} | |
componentWillUnmount() { | |
this.refs.gameCanvas.removeChild(this.renderer.view); | |
this.renderer.destroy( true ); | |
this.renderer = null; | |
this.frame = null; | |
cancelAnimationFrame(this.frame); | |
} | |
render() { | |
return ( | |
<div className="game-canvas-container" ref="gameCanvas"> | |
</div> | |
); | |
} | |
} | |
class Application extends React.Component { | |
constructor(props) { | |
super(props); | |
//store our zoom level in state | |
this.onResetGame = this.onResetGame.bind(this); | |
} | |
/** | |
* Event handler for resetting. Increments the count | |
**/ | |
onResetGame() { | |
console.log("in canvas, clicked reset"); | |
g.state = play; | |
g.count = 0; | |
g.state = reset; | |
g.state(); | |
} | |
render() { | |
return ( | |
<div> | |
<button className='button' onClick={this.onResetGame}>Reset Game</button> | |
<Canvas /> | |
</div> | |
); | |
} | |
} | |
ReactDOM.render(<Application />, document.getElementById("appContainer")); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Rogue Hexi.js and React.js Game for FCC</title> | |
<meta charset="utf-8"> | |
<!-- | |
/* | |
$bgcolor: rgb(249,252,253); | |
$buttonfont: normal 20px/1.5 'Open Sans', sans-serif; | |
$whitish: #F44336; | |
.button { | |
position: absolute; | |
left:20px; | |
margin: 5px; | |
top: 5px; | |
background-color:$bgcolor; | |
height: 45px; | |
width: 55px; | |
font-family: $buttonfont; | |
font-color: "black"; | |
} | |
compiled to css with node-sass-chokidar | |
*/ | |
--> | |
<link rel="stylesheet" type="text/css" href="game.css"> | |
</head> | |
<body> | |
<div id="outerMask"> | |
<div class="app-container" id="appContainer"></div> | |
</div> | |
<script src="https://cdn.rawgit.com/kittykatattack/hexi/835464c9/bin/hexi.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.2/react.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/15.0.2/react-dom.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js"></script> | |
<script type="text/javascript" src="game.js"></script> | |
<script type="text/babel" src="gamereact.js"></script> | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment