Last active
September 9, 2016 16:24
-
-
Save DolenzSong/f9b0d5e0253cbfee70f58030b327d79a to your computer and use it in GitHub Desktop.
Fake 3D physics for Codea, based off of Ignatz's version
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
--# Main | |
saveImage("Project:Icon", readImage("SpaceCute:Planet")) | |
if notSet then print "notSet is true" else print "notSet is nil and false" end | |
existenceIsTruth = "wow" | |
if existenceIsTruth then print("existenceIsTruth: "..existenceIsTruth) end | |
displayMode(OVERLAY) | |
supportedOrientations(LANDSCAPE_ANY) | |
function setup() | |
makeCameraControls() | |
setPhysicsVariables() | |
boxSize=HEIGHT --size of virtual box | |
radius = 35 | |
proportion = 0.2604 | |
boxSize3D = boxSize*proportion --size of 3D cube | |
radius3D = radius*proportion | |
fake3D = {} | |
Create3DCube(boxSize3D) | |
physicsLabSetup() | |
notStarted=true | |
end | |
function makeCameraControls() | |
parameter.number("cameraX", -200, 200, -15) | |
parameter.number("cameraY", -200, 200, 90) | |
parameter.number("cameraZ", -200, 200, 60) | |
parameter.number("lookX", -200, 200, -15) | |
parameter.number("lookY", -200, 200, -90) | |
parameter.number("lookZ", -200, 200, -90) | |
end | |
function setPhysicsVariables() | |
ballBounciness = 1 | |
ballGravityScale = 1 | |
wallBounciness = 1 | |
end | |
function touched(t) | |
if t.tapCount==2 and t.state == ENDED then | |
local newFake = Fake3DSphere(t.x, t.y, radius) | |
table.insert(fake3D, newFake) | |
notStarted=false | |
end | |
if t.tapCount==5 and t.state == ENDED then | |
local center1 = vec2(t.x, t.y) | |
local center2 = vec2(t.x+radius*2, t.y) | |
local joint1Pos = vec2(t.x+radius, t.y) | |
local newFake = Fake3DSphere(center1.x, center1.y, radius) | |
local newFake2 = Fake3DSphere(center2.x, center2.y, radius) | |
local center1a = newFake.circles[2].position | |
local joint2Pos = vec2(center1a.x+radius, center1a.y) | |
local joint1 = physics.joint(WELD, newFake.circles[1],newFake2.circles[1],joint1Pos) | |
local joint2 = physics.joint(WELD, newFake.circles[2],newFake2.circles[2],joint2Pos) | |
joint1.frequency = 1 | |
joint2.frequency = 1 | |
debugDraw:addJoint(joint1) | |
debugDraw:addJoint(joint2) | |
table.insert(fake3D, newFake) | |
table.insert(fake3D, newFake2) | |
end | |
debugDraw:touched(t) | |
end | |
function Create3DCube(size) --size is size of 3D box | |
local img=readImage("SpaceCute:Background")--:copy(4,4,62,62) | |
box=CreateBlock(size,size,size,color(255),vec3(0,0,0),img) | |
end | |
function draw() | |
background(220) | |
perspective(90) | |
camera(cameraX,cameraY,cameraZ,lookX,lookY,lookZ) | |
box:draw() | |
for i, fake in ipairs(fake3D) do | |
fake:updatePosition() | |
pushMatrix() | |
translate(fake.sphere.position:unpack()) | |
fake.sphere.shader.mModel=modelMatrix() | |
fake.sphere.shader.centre=vec4(fake.sphere.position.x,fake.sphere.position.y,fake.sphere.position.z,1) | |
fake.sphere:draw() | |
popMatrix() | |
end | |
ortho() | |
viewMatrix(matrix()) | |
stroke(0) | |
strokeWidth(1) | |
physicsLabDraw() | |
end | |
--# Shader | |
S = { | |
v = [[ | |
uniform mat4 modelViewProjection; | |
uniform mat4 mModel; | |
attribute vec4 position; | |
attribute vec4 color; | |
attribute vec2 texCoord; | |
varying highp vec2 vTexCoord; | |
varying highp vec4 vPosition; | |
void main() | |
{ | |
vTexCoord = texCoord; | |
vPosition = mModel * position; | |
gl_Position = modelViewProjection * position; | |
} | |
]], | |
f = [[ | |
precision highp float; | |
uniform lowp sampler2D texture; | |
uniform vec4 ambientColor; | |
uniform vec3 directDirection; | |
uniform vec4 directColor; | |
uniform vec4 centre; | |
varying highp vec2 vTexCoord; | |
varying highp vec4 vPosition; | |
void main() | |
{ | |
vec4 col = texture2D(texture, vTexCoord); | |
vec4 norm = normalize(vPosition-centre); | |
float diffuse = max( 0.0, dot( norm.xyz, directDirection )); //calculate strength of reflection | |
col = col * (ambientColor + diffuse * directColor); //total color | |
col.a = 1.0; | |
gl_FragColor=col; | |
} | |
]] | |
} | |
--# Utility | |
function CreateBlock(w,h,d,col,pos,tex) --width,height,depth,colour,position,texture | |
local x,X,y,Y,z,Z=pos.x-w/2,pos.x+w/2,pos.y-h/2,pos.y+h/2,pos.z-d/2,pos.z+d/2 | |
local v={vec3(x,y,Z),vec3(X,y,Z),vec3(X,Y,Z),vec3(x,Y,Z),vec3(x,y,z),vec3(X,y,z),vec3(X,Y,z),vec3(x,Y,z)} | |
local vert={v[1],v[2],v[3],v[1],v[3],v[4],v[2],v[6],v[7],v[2],v[7],v[3],v[6],v[5],v[8],v[6],v[8],v[7], | |
v[5],v[1],v[4],v[5],v[4],v[8],v[4],v[3],v[7],v[4],v[7],v[8],v[5],v[6],v[2],v[5],v[2],v[1]} | |
local texCoords | |
if tex then | |
local t={vec2(0,0),vec2(1,0),vec2(0,1),vec2(1,1)} | |
texCoords={t[1],t[2],t[4],t[1],t[4],t[3],t[1],t[2],t[4],t[1],t[4],t[3],t[1],t[2],t[4],t[1],t[4],t[3], | |
t[1],t[2],t[4],t[1],t[4],t[3],t[1],t[2],t[4],t[1],t[4],t[3],t[1],t[2],t[4],t[1],t[4],t[3]} | |
end | |
local n={vec3(0,0,1),vec3(1,0,0),vec3(0,0,-1),vec3(-1,0,0),vec3(1,0,0),vec3(-1,0,0)} | |
local norm={} | |
for i=1,6 do for j=1,6 do norm[#norm+1]=n[i] end end | |
local ms = mesh() | |
ms.vertices = vert | |
ms.normals=norm | |
if tex then ms.texture,ms.texCoords = tex,texCoords end | |
ms:setColors(col or color(255)) | |
return ms | |
end | |
function CreateSphere(r,tex,col,nx,ny) | |
local vertices,tc = Sphere_OptimMesh(nx or 40,ny or 20) | |
vertices = Sphere_WarpVertices(vertices) | |
for i=1,#vertices do vertices[i]=vertices[i]*r end | |
local ms = mesh() | |
ms.vertices=vertices | |
if tex then ms.texture,ms.texCoords=tex,tc end | |
ms:setColors(col or color(255)) | |
return ms | |
end | |
function Sphere_OptimMesh(nx,ny) | |
local v,t={},{} | |
local k,s,x,y,x1,x2,i1,i2,sx,sy=0,1,0,0,{},{},0,0,nx/ny,1/ny | |
local c = vec3(1,0.5,0) | |
local m1,m2 | |
for y=0,ny-1 do | |
local nx1 = math.floor( nx * math.abs(math.cos(( y*sy-0.5)*2 * math.pi/2)) ) | |
if nx1<6 then nx1=6 end | |
local nx2 = math.floor( nx * math.abs(math.cos(((y+1)*sy-0.5)*2 * math.pi/2)) ) | |
if nx2<6 then nx2=6 end | |
x1,x2 = {},{} | |
for i1 = 1,nx1 do x1[i1] = (i1-1)/(nx1-1)*sx end x1[nx1+1] = x1[nx1] | |
for i2 = 1,nx2 do x2[i2] = (i2-1)/(nx2-1)*sx end x2[nx2+1] = x2[nx2] | |
local i1,i2,n,nMax,continue=1,1,0,0,true | |
nMax = nx*2+1 | |
while continue do | |
m1,m2=(x1[i1]+x1[i1+1])/2,(x2[i2]+x2[i2+1])/2 | |
if m1<=m2 then | |
v[k+1],v[k+2],v[k+3]=vec3(x1[i1],sy*y,1)-c,vec3(x1[i1+1],sy*y,1)-c,vec3(x2[i2],sy*(y+1),1)-c | |
t[k+1],t[k+2],t[k+3]=vec2(-x1[i1]/2,sy*y) ,vec2(-x1[i1+1]/2,sy*y),vec2(-x2[i2]/2,sy*(y+1)) | |
if i1<nx1 then i1 = i1 +1 end | |
else | |
v[k+1],v[k+2],v[k+3]=vec3(x1[i1],sy*y,1)-c,vec3(x2[i2],sy*(y+1),1)-c,vec3(x2[i2+1],sy*(y+1),1)-c | |
t[k+1],t[k+2],t[k+3]=vec2(-x1[i1]/2,sy*y),vec2(-x2[i2]/2,sy*(y+1)),vec2(-x2[i2+1]/2,sy*(y+1)) | |
if i2<nx2 then i2 = i2 +1 end | |
end | |
if i1==nx1 and i2==nx2 then continue=false end | |
k,n=k+3,n+1 | |
if n>nMax then continue=false end | |
end | |
end | |
return v,t | |
end | |
function Sphere_WarpVertices(verts) | |
local m = matrix(0,0,0,0, 0,0,0,0, 1,0,0,0, 0,0,0,0) | |
local vx,vy,vz,vm | |
for i,v in ipairs(verts) do | |
vx,vy = v[1], v[2] | |
vm = m:rotate(180*vy,1,0,0):rotate(180*vx,0,1,0) | |
vx,vy,vz = vm[1],vm[5],vm[9] | |
verts[i] = vec3(vx,vy,vz) | |
end | |
return verts | |
end | |
--# PhysicsLabMain | |
--supportedOrientations(LANDSCAPE_ANY) | |
-- Use this function to perform your initial setup | |
function physicsLabSetup() | |
lineCapMode(ROUND) | |
debugDraw = PhysicsDebugDraw() | |
--defaultGravity = physics.gravity() | |
createGround() | |
end | |
function createCircle(x,y,r) | |
local circle = physics.body(CIRCLE, r) | |
-- enable smooth motion | |
circle.interpolate = true | |
circle.x = x | |
circle.y = y | |
circle.restitution = 0.25 | |
circle.sleepingAllowed = false | |
debugDraw:addBody(circle) | |
return circle | |
end | |
--[[ | |
function createFake3DCircles(x,y,r) | |
local fakers = {} | |
for i=0,1 do | |
local circle = physics.body(CIRCLE, r) | |
local sensor = physics.body(CIRCLE, r) | |
-- enable smooth motion | |
circle.interpolate = true | |
sensor.interpolate = true | |
circle.x = x +(i*r*2.2) | |
circle.y = y | |
circle.restitution = 1 | |
circle.sleepingAllowed = true | |
sensor.sensor = true | |
sensor.gravityScale = 0 | |
sensor.sleepingAllowed = true | |
sensor.position = circle.position | |
sensor.density = 0 | |
sensor.mass = 0 | |
sensor.mask = {1,i+1} | |
sensor.categories = {} | |
sensor.weldedTo = circle | |
sensor.plane = i | |
local distJoint = physics.joint(WELD, circle, sensor, circle.position, sensor.position, 10) | |
debugDraw:addJoint(distJoint) | |
circle.mask = {1} | |
circle.categories = {i+2} | |
table.insert(fakers, sensor) | |
end | |
fakers[1].linkedTo = fakers[2] | |
fakers[2].linkedTo = fakers[1] | |
for i=1,2 do | |
debugDraw:addBody(fakers[i]) | |
debugDraw:addBody(fakers[i].weldedTo) | |
end | |
end | |
]] | |
function createBox(x,y,w,h) | |
-- polygons are defined by a series of points in counter-clockwise order | |
local box = physics.body(POLYGON, vec2(-w/2,h/2), vec2(-w/2,-h/2), vec2(w/2,-h/2), vec2(w/2,h/2)) | |
box.interpolate = true | |
box.x = x | |
box.y = y | |
box.restitutions = 0.25 | |
box.sleepingAllowed = false | |
debugDraw:addBody(box) | |
return box | |
end | |
function createGround() | |
local ground = physics.body(POLYGON, vec2(20,20), vec2(20,0), vec2(boxSize+20,0), vec2(boxSize+20,20)) | |
ground.type = STATIC | |
ground.restitution = wallBounciness | |
debugDraw:addBody(ground) | |
local wallLeft = physics.body(POLYGON, vec2(boxSize+20,20), vec2(boxSize+40,20), vec2(boxSize + 40,boxSize), vec2(boxSize+20, boxSize)) | |
wallLeft.type = STATIC | |
wallLeft.restitution = wallBounciness | |
debugDraw:addBody(wallLeft) | |
local wallRight = physics.body(POLYGON, vec2(0,20), vec2(20,20), vec2(20,boxSize), vec2(0, boxSize)) | |
wallRight.type = STATIC | |
wallRight.restitution = wallBounciness | |
debugDraw:addBody(wallRight) | |
return ground | |
end | |
function createRandPoly(x,y) | |
local count = math.random(3,10) | |
local r = math.random(25,75) | |
local a = 0 | |
local d = 2 * math.pi / count | |
local points = {} | |
for i = 1,count do | |
local v = vec2(r,0):rotate(a) + vec2(math.random(-10,10), math.random(-10,10)) | |
a = a + d | |
table.insert(points, v) | |
end | |
local poly = physics.body(POLYGON, unpack(points)) | |
poly.x = x | |
poly.y = y | |
poly.sleepingAllowed = false | |
poly.restitution = 0.25 | |
debugDraw:addBody(poly) | |
return poly | |
end | |
function cleanup() | |
clearOutput() | |
debugDraw:clear() | |
end | |
-- This function gets called once every frame | |
function physicsLabDraw() | |
for i,fake in ipairs(fake3D) do | |
--fake:updatePosition() --has had to be put in main draw | |
end | |
debugDraw:draw() | |
font("Vegur-Bold") | |
fontSize(22) | |
fill(255, 255, 255, 255) | |
--physics.gravity(defaultGravity) --has to be being set somewhere else? | |
physics.gravity(Gravity) | |
end | |
function collide(contact) | |
if contact.bodyA.fake3DLink ~= nil and contact.bodyB.fake3DLink ~= nil then | |
if contact.state == BEGAN then | |
if contact.bodyA.fake3DLink:bothCirclesContacting(contact.bodyB) then | |
--set masks and categories on bodies | |
contact.bodyA.fake3DLink:addToMasksAndCategories(3) | |
contact.bodyB.fake3DLink:addToMasksAndCategories(3) | |
end | |
elseif contact.bodyA.fake3DLink:isCollidable() then | |
contact.bodyA.fake3DLink:resetMasksAndCategories() | |
contact.bodyB.fake3DLink:resetMasksAndCategories() | |
end | |
end | |
if debugDraw then | |
debugDraw:collide(contact) | |
end | |
if currentTest and currentTest.collide then | |
currentTest:collide(contact) | |
end | |
end | |
--# PhysicsDebugDraw | |
PhysicsDebugDraw = class() | |
function PhysicsDebugDraw:init() | |
self.bodies = {} | |
self.joints = {} | |
self.touchMap = {} | |
self.contacts = {} | |
end | |
function PhysicsDebugDraw:addBody(body) | |
table.insert(self.bodies,body) | |
end | |
function PhysicsDebugDraw:addJoint(joint) | |
table.insert(self.joints,joint) | |
end | |
function PhysicsDebugDraw:clear() | |
-- deactivate all bodies | |
for i,body in ipairs(self.bodies) do | |
body:destroy() | |
end | |
for i,joint in ipairs(self.joints) do | |
joint:destroy() | |
end | |
self.bodies = {} | |
self.joints = {} | |
self.contacts = {} | |
self.touchMap = {} | |
end | |
function PhysicsDebugDraw:draw() | |
local drawAlpha = 15 | |
pushStyle() | |
smooth() | |
strokeWidth(5) | |
local gain = 2.0 | |
local damp = 0.5 | |
stroke(255, 0, 241, drawAlpha) | |
for k,v in pairs(self.touchMap) do | |
local worldAnchor = v.body:getWorldPoint(v.anchor) | |
local touchPoint = v.tp | |
local diff = touchPoint - worldAnchor | |
local vel = v.body:getLinearVelocityFromWorldPoint(worldAnchor) | |
v.body:applyForce( (1/1) * diff * gain - vel * damp, worldAnchor) | |
line(touchPoint.x, touchPoint.y, worldAnchor.x, worldAnchor.y) | |
end | |
stroke(0,255,0,drawAlpha) | |
strokeWidth(5) | |
for k,joint in pairs(self.joints) do | |
local a = joint.anchorA | |
local b = joint.anchorB | |
--line(a.x,a.y,b.x,b.y) --uncomment to see joints | |
end | |
stroke(255,255,255,drawAlpha) | |
noFill() | |
for i,body in ipairs(self.bodies) do | |
pushMatrix() | |
translate(body.x, body.y) | |
rotate(body.angle) | |
if body.type == STATIC then | |
stroke(255,255,255,drawAlpha) | |
elseif body.type == DYNAMIC then | |
if body.categories[1] == 2 then | |
stroke(150,255,150,drawAlpha) | |
else | |
stroke(225, 167, 90, drawAlpha) | |
end | |
elseif body.type == KINEMATIC then | |
stroke(150,150,255,drawAlpha) | |
end | |
if body.shapeType == POLYGON then | |
strokeWidth(3.0) | |
local points = body.points | |
for j = 1,#points do | |
a = points[j] | |
b = points[(j % #points)+1] | |
line(a.x, a.y, b.x, b.y) | |
end | |
elseif body.shapeType == CHAIN or body.shapeType == EDGE then | |
strokeWidth(3.0) | |
local points = body.points | |
for j = 1,#points-1 do | |
a = points[j] | |
b = points[j+1] | |
line(a.x, a.y, b.x, b.y) | |
end | |
elseif body.shapeType == CIRCLE then | |
strokeWidth(3.0) | |
line(0,0,body.radius-3,0) | |
ellipse(0,0,body.radius*2) | |
end | |
popMatrix() | |
end | |
stroke(255, 0, 0, drawAlpha) | |
fill(255, 0, 0, drawAlpha) | |
for k,v in pairs(self.contacts) do | |
for m,n in ipairs(v.points) do | |
ellipse(n.x, n.y, 10, 10) | |
end | |
end | |
popStyle() | |
end | |
function PhysicsDebugDraw:touched(touch) | |
local touchPoint = vec2(touch.x, touch.y) | |
if touch.state == BEGAN then | |
for i,body in ipairs(self.bodies) do | |
if body.type == DYNAMIC and body:testPoint(touchPoint) then | |
self.touchMap[touch.id] = {tp = touchPoint, body = body, anchor = body:getLocalPoint(touchPoint)} | |
return true | |
end | |
end | |
elseif touch.state == MOVING and self.touchMap[touch.id] then | |
self.touchMap[touch.id].tp = touchPoint | |
return true | |
elseif touch.state == ENDED and self.touchMap[touch.id] then | |
self.touchMap[touch.id] = nil | |
return true; | |
end | |
return false | |
end | |
function PhysicsDebugDraw:collide(contact) | |
if contact.state == BEGAN then | |
self.contacts[contact.id] = contact | |
--sound(SOUND_HIT, 26498888) | |
elseif contact.state == MOVING then | |
self.contacts[contact.id] = contact | |
elseif contact.state == ENDED then | |
self.contacts[contact.id] = nil | |
end | |
end | |
--# Fake3DSphere | |
Fake3DSphere = class() | |
function Fake3DSphere:init(x, y, radius) | |
self.circles = {} | |
self.sensorCircles = {} | |
local theseCircles = {} | |
for i=0,1 do | |
local circle = physics.body(CIRCLE, radius) | |
-- enable smooth motion | |
circle.interpolate = true | |
circle.x = x +(i*radius*2.2) | |
circle.y = y | |
circle.restitution = ballBounciness | |
circle.gravityScale = ballGravityScale | |
circle.sleepingAllowed = true | |
circle.mask = {1} | |
circle.categories = {i+2} | |
circle.fixedRotation = true | |
table.insert(self.circles, circle) | |
local sensorCircle = physics.body(CIRCLE, radius) | |
sensorCircle.position = circle.position | |
sensorCircle.interpolate = true | |
sensorCircle.sensor = true | |
sensorCircle.fake3DLink = self | |
sensorCircle.plane = i | |
sensorCircle.gravityScale = 0 | |
sensorCircle.sleepingAllowed = true | |
sensorCircle.density = 0 | |
sensorCircle.frequency = 0 | |
sensorCircle.mass = 0 | |
--sensorCircle.mask = {1,i+1} | |
--sensorCircle.categories = {} | |
table.insert(self.sensorCircles, sensorCircle) | |
table.insert(theseCircles, circle) | |
-- WELD holds the circle and the sensor together | |
-- | |
local distJoint = physics.joint(WELD, circle, sensorCircle, circle.position) | |
debugDraw:addJoint(distJoint) | |
--]] | |
debugDraw:addBody(sensorCircle) | |
debugDraw:addBody(circle) -- must be added after sensor to draw correctly | |
end | |
self.sphere=CreateSphere(radius3D*1.5,readImage("Cargo Bot:Starry Background"),color(255)) | |
self.sphere.shader=shader(S.v,S.f) | |
self.sphere.shader.ambientColor=color(255)*0.5 | |
self.sphere.shader.directDirection=vec3(-1,1,-1):normalize() | |
self.sphere.shader.directColor=color(255)*0.5 | |
self.sphere.fake3DLink = self | |
-- joint for constraining y values between circles | |
-- | |
local wheelJoint = physics.joint(PRISMATIC, theseCircles[1], theseCircles[2], theseCircles[1].position, vec2(1,0)) | |
--wheelJoint.enableLimit = true | |
debugDraw:addJoint(wheelJoint) | |
--]] | |
end | |
function Fake3DSphere:updatePosition() | |
--[[ | |
--works but stutters, tries to override physics engine: | |
if self.circles[1].y ~= self.circles[2].y then | |
local newY = (self.circles[1].y + self.circles[2].y) / 2 | |
self.circles[1].y = newY | |
self.circles[2].y = newY | |
end | |
]] | |
--[[ these were replaced by WELD, used to enforce sensor and circle positions | |
self.sensorCircles[1].x = self.circles[1].x | |
self.sensorCircles[1].y = self.circles[1].y | |
self.sensorCircles[2].x = self.circles[2].x | |
self.sensorCircles[2].y = self.circles[2].y | |
]] | |
local offset = boxSize/2 | |
self.sphere.position = vec3(self.circles[1].x-offset,self.circles[1].y-offset, self.circles[2].x-offset)*proportion | |
end | |
function Fake3DSphere:bothCirclesContacting(otherBody) | |
if otherBody.fake3DLink == nil then | |
return false | |
end | |
if self.sensorCircles[1]:testOverlap(otherBody.fake3DLink.sensorCircles[1]) then | |
if self.sensorCircles[2]:testOverlap(otherBody.fake3DLink.sensorCircles[2]) then | |
return true | |
end | |
else | |
return false | |
end | |
end | |
function Fake3DSphere:addToMasksAndCategories(number) | |
--4 and 5 are added to number to prevent confusion with existing values (1-3) | |
self.circles[1].mask = {1, 4+number} | |
self.circles[2].mask = {1, 5+number} | |
self.circles[1].categories = {2, 4+number} | |
self.circles[2].categories = {3, 5+number} | |
--[[ | |
print("myMask1:", table.unpack(self.circles[1].mask)) | |
print("myMask2:", table.unpack(self.circles[2].mask)) | |
print("categories1:", table.unpack(self.circles[1].categories)) | |
print("categories2:", table.unpack(self.circles[2].categories)) | |
]] | |
end | |
function Fake3DSphere:resetMasksAndCategories() | |
--print("masks and categories reset") | |
self.circles[1].mask = {1} | |
self.circles[2].mask = {1} | |
self.circles[1].categories = {2} | |
self.circles[2].categories = {3} | |
end | |
function Fake3DSphere:isCollidable() | |
-- true if there's more than one value in any mask | |
return #self.circles[1].mask > 1 | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment