Try your hand against the worst AI to ever play pool!
A Pen by HARUN PEHLİVAN on CodePen.
Try your hand against the worst AI to ever play pool!
A Pen by HARUN PEHLİVAN on CodePen.
| <section> | |
| <div class="score"></div> | |
| <div class="canvas"></div> | |
| <div class="message"></div> | |
| </section> |
| console.clear(); | |
| noise.seed(Math.random() * 1000); | |
| const ASSET_PREFIX = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/111863/'; | |
| const PI = Math.PI, | |
| PI2 = PI * 2; | |
| const // module aliases | |
| Bodies = Matter.Bodies, | |
| Body = Matter.Body, | |
| Composite = Matter.Composite, | |
| Constraint = Matter.Constraint, | |
| Engine = Matter.Engine, | |
| Events = Matter.Events, | |
| Mouse = Matter.Mouse, | |
| MouseConstraint = Matter.MouseConstraint, | |
| Render = Matter.Render, | |
| Vector = Matter.Vector, | |
| World = Matter.World; | |
| const COLORS = { | |
| white: 'white', | |
| red: '#F44336', | |
| black: '#212121', | |
| purple: '#9C27B0', | |
| blue: '#2196F3', | |
| green: '#8bc34a', | |
| yellow: '#FFC107', | |
| orange: '#FF9800', | |
| brown: '#795548', | |
| felt: '#757575', | |
| pocket: '#121212', | |
| frame: '#3E2723' | |
| }; | |
| const WIREFRAMES = false, | |
| INCH = 12, | |
| FOOT = INCH * 12, | |
| BALL_DI = 2.4375 * INCH, | |
| BALL_RAD = BALL_DI / 2, | |
| // POCKET_DI = 3.5 * INCH, | |
| POCKET_DI = 4.5 * INCH, | |
| POCKET_RAD = POCKET_DI / 2, | |
| WALL_DI = 5 * INCH, | |
| WALL_RAD = WALL_DI / 2, | |
| // TABLE_W = 9 * FOOT, | |
| TABLE_W = 8 * FOOT, | |
| // TABLE_H = 4.5 * FOOT, | |
| TABLE_H = 3.5 * FOOT, | |
| RETURN_H = BALL_DI * 1.75, | |
| VIEW_W = WALL_DI * 2 + TABLE_W, | |
| VIEW_H = WALL_DI * 2 + TABLE_H + RETURN_H; | |
| class Ball { | |
| constructor({ number, cueball }) { | |
| this.cue = number === 0; | |
| this.eight = number === 8; | |
| this.stripes = number > 8; | |
| this.solids = number > 0 && number < 8; | |
| this.number = number; | |
| this.diameter = BALL_DI; | |
| this.pocketed = false; | |
| this.setInitialCoordinates(); | |
| this.setRender(); | |
| this.setColor(); | |
| this.cueball = cueball; | |
| this.build(); | |
| } | |
| get fromCueball() { | |
| return { | |
| angle: Vector.angle(this.body.position, this.cueball.body.position) | |
| }; | |
| } | |
| setInitialCoordinates() { | |
| let pos = Ball.positions[this.number].map(p => rel(p)); | |
| this.x = pos[0]; | |
| this.y = pos[1]; | |
| } | |
| setColor() { | |
| this.color = COLORS[[ | |
| 'white', | |
| 'yellow', 'blue', 'red', 'purple', 'orange', 'green', 'brown', | |
| 'black', | |
| 'yellow', 'blue', 'red', 'purple', 'orange', 'green', 'brown' | |
| ][this.number]]; | |
| } | |
| setRender() { | |
| this.render = { fillStyle: 'transparent', lineWidth: 0 }; | |
| } | |
| enable() { | |
| Body.setStatic(this.body, false); | |
| this.pocketed = false; | |
| this.body.isSensor = false; | |
| } | |
| disable() { | |
| if (!this.cue) Body.setStatic(this.body, true); | |
| this.pocketed = true; | |
| this.body.isSensor = true; | |
| } | |
| rest() { | |
| this.setVelocity({ x: 0, y: 0 }); | |
| Body.setPosition(this.body, this.body.position); | |
| Body.update(this.body, 0, 0, 0); | |
| } | |
| reset() { | |
| this.enable(); | |
| this.setVelocity({ x: 0, y: 0 }); | |
| Body.setPosition(this.body, { x: this.x, y: this.y }); | |
| } | |
| pocket({ x, y }) { | |
| this.disable(); | |
| Body.setVelocity(this.body, { x: 0, y: 0 }); | |
| Body.setAngle(this.body, 0); | |
| Body.setPosition(this.body, { x, y }); | |
| Body.update(this.body, 0, 0, 0); | |
| } | |
| setVelocity({ x, y }) { | |
| Body.setVelocity(this.body, { x, y }); | |
| } | |
| build() { | |
| this.body = Bodies.circle( | |
| this.x, this.y, | |
| this.diameter / 2, | |
| { | |
| render: this.render, | |
| label: 'ball', | |
| restitution: 0.9, | |
| friction: 0.001, | |
| density: this.cue ? 0.00021 : 0.0002 | |
| } | |
| ); | |
| } | |
| static get positions() { | |
| let radians60 = 60 * (Math.PI / 180), | |
| radians60Sin = Math.sin(radians60), | |
| radians60Cos = Math.cos(radians60); | |
| let postStartX = TABLE_W - TABLE_W / 4, | |
| postStartY = TABLE_H / 2, | |
| pos1 = [postStartX, postStartY], | |
| pos2 = [postStartX + (radians60Sin * BALL_DI), | |
| postStartY - (radians60Cos * BALL_DI)], | |
| pos3 = [postStartX + (radians60Sin * (BALL_DI * 2)), | |
| postStartY - (radians60Cos * (BALL_DI * 2))], | |
| pos4 = [postStartX + (radians60Sin * (BALL_DI * 3)), | |
| postStartY - (radians60Cos * (BALL_DI * 3))], | |
| pos5 = [postStartX + (radians60Sin * (BALL_DI * 4)), | |
| postStartY - (radians60Cos * (BALL_DI * 4))]; | |
| return [ | |
| [ // cue | |
| TABLE_W / 4, TABLE_H / 2], | |
| pos1, // 1 | |
| pos2, // 2 | |
| pos3, // 3 | |
| pos4, // 4 | |
| [ // 5 | |
| pos1[0] + (radians60Sin * (BALL_DI * 4)), | |
| pos1[1] + (radians60Cos * (BALL_DI * 4))], | |
| [ // 6 | |
| pos4[0] + (radians60Sin * BALL_DI), | |
| pos4[1] + (radians60Cos * BALL_DI)], | |
| [ // 7 | |
| pos2[0] + (radians60Sin * BALL_DI * 2), | |
| pos2[1] + (radians60Cos * BALL_DI * 2)], | |
| [ // 8 | |
| pos2[0] + (radians60Sin * BALL_DI), | |
| pos2[1] + (radians60Cos * BALL_DI)], | |
| [ // 9 | |
| pos1[0] + (radians60Sin * BALL_DI), | |
| pos1[1] + (radians60Cos * BALL_DI)], | |
| [ // 10 | |
| pos1[0] + (radians60Sin * (BALL_DI * 2)), | |
| pos1[1] + (radians60Cos * (BALL_DI * 2))], | |
| [ // 11 | |
| pos1[0] + (radians60Sin * (BALL_DI * 3)), | |
| pos1[1] + (radians60Cos * (BALL_DI * 3))], | |
| pos5, // 12 | |
| [ // 13 | |
| pos2[0] + (radians60Sin * (BALL_DI * 3)), | |
| pos2[1] + (radians60Cos * (BALL_DI * 3))], | |
| [ // 14 | |
| pos3[0] + (radians60Sin * BALL_DI), | |
| pos3[1] + (radians60Cos * BALL_DI)], | |
| [ // 15 | |
| pos3[0] + (radians60Sin * (BALL_DI * 2)), | |
| pos3[1] + (radians60Cos * (BALL_DI * 2))], | |
| ]; | |
| } | |
| } | |
| class Table { | |
| constructor() { | |
| this.width = TABLE_W; | |
| this.height = TABLE_H; | |
| this.hypot = Math.hypot(TABLE_W, TABLE_H); | |
| this.build(); | |
| } | |
| build() { | |
| this.buildBounds(); | |
| this.buildWall(); | |
| this.buildPockets(); | |
| } | |
| buildBounds() { | |
| let boundsOptions = { | |
| isStatic: true, | |
| render: { fillStyle: 'red' }, | |
| label: 'bounds', | |
| friction: 1, | |
| restitution: 0, | |
| density: 1 | |
| }; | |
| let hw = VIEW_W + VIEW_H * 2; | |
| let vw = VIEW_H; | |
| let h = VIEW_H; | |
| this.bounds = [ | |
| // Top | |
| Bodies.rectangle(VIEW_W * 0.5, h * -0.5, hw, h, boundsOptions), | |
| // Bottom | |
| Bodies.rectangle(VIEW_W * 0.5, VIEW_H + h * 0.5, hw, h, boundsOptions), | |
| // Left | |
| Bodies.rectangle(vw * -0.5, VIEW_H * 0.5, vw, h, boundsOptions), | |
| // Left | |
| Bodies.rectangle(VIEW_W + vw * 0.5, VIEW_H * 0.5, vw, h, boundsOptions) | |
| ]; | |
| } | |
| buildWall() { | |
| let wallOptions = { | |
| isStatic: true, | |
| render: { fillStyle: 'transparent' }, | |
| label: 'wall', | |
| friction: 0.0025, | |
| restitution: 0.6, | |
| density: 0.125, | |
| slop: 0.5 | |
| }; | |
| let quarterW = (TABLE_W - POCKET_RAD * 2) / 4; | |
| let halfH = (TABLE_H - POCKET_RAD) / 2; | |
| let vertices = Table.wallVertices; | |
| let horizontalBlock = { width: WALL_DI * 1.5, height: WALL_DI - POCKET_RAD }; | |
| let verticalBlock = { width: WALL_DI - POCKET_RAD, height: WALL_DI * 1.5 }; | |
| let middleBlock = { width: WALL_DI - POCKET_RAD, height: WALL_DI - POCKET_RAD }; | |
| let horTY = horizontalBlock.height / 2, | |
| horBY = rel(TABLE_H + WALL_DI - horizontalBlock.height / 2), | |
| horLX = horizontalBlock.width / 2, | |
| horRX = rel(TABLE_W + WALL_DI - horizontalBlock.width / 2), | |
| verTY = verticalBlock.height / 2, | |
| verBY = rel(TABLE_H + WALL_DI - verticalBlock.height / 2), | |
| verLX = verticalBlock.width / 2, | |
| verRX = rel(TABLE_W + WALL_DI - verticalBlock.width / 2) | |
| ; | |
| this.walls = [ | |
| // Bottom Left | |
| Bodies.fromVertices(rel(TABLE_W / 4), rel(TABLE_H + WALL_RAD), vertices.bottom, wallOptions), | |
| // Bottom Right | |
| Bodies.fromVertices(rel(TABLE_W / 4 + TABLE_W / 2), rel(TABLE_H + WALL_RAD), vertices.bottom, wallOptions), | |
| // Top Left | |
| Bodies.fromVertices(rel(TABLE_W / 4), rel(0 - WALL_RAD), vertices.top, wallOptions), | |
| // Top Right | |
| Bodies.fromVertices(rel(TABLE_W / 4 + TABLE_W / 2), rel(0 - WALL_RAD), vertices.top, wallOptions), | |
| // Left | |
| Bodies.fromVertices(rel(0 - WALL_RAD), rel(TABLE_H / 2), vertices.left, wallOptions), | |
| // Right | |
| Bodies.fromVertices(rel(TABLE_W + WALL_RAD), rel(TABLE_H / 2), vertices.right, wallOptions), | |
| // TL horizontal | |
| Bodies.rectangle(horLX, horTY, horizontalBlock.width, horizontalBlock.height, wallOptions), | |
| // TR horizontal | |
| Bodies.rectangle(horRX, horTY, horizontalBlock.width, horizontalBlock.height, wallOptions), | |
| // BL horizontal | |
| Bodies.rectangle(horLX, horBY, horizontalBlock.width, horizontalBlock.height, wallOptions), | |
| // BR horizontal | |
| Bodies.rectangle(horRX, horBY, horizontalBlock.width, horizontalBlock.height, wallOptions), | |
| // TL vertical | |
| Bodies.rectangle(verLX, verTY, verticalBlock.width, verticalBlock.height, wallOptions), | |
| // TR vertical | |
| Bodies.rectangle(verRX, verTY, verticalBlock.width, verticalBlock.height, wallOptions), | |
| // BL vertical | |
| Bodies.rectangle(verLX, verBY, verticalBlock.width, verticalBlock.height, wallOptions), | |
| // BR vertical | |
| Bodies.rectangle(verRX, verBY, verticalBlock.width, verticalBlock.height, wallOptions), | |
| // B middle | |
| Bodies.rectangle(rel(TABLE_W / 2), horBY, middleBlock.width, middleBlock.height, wallOptions), | |
| // T middle | |
| Bodies.rectangle(rel(TABLE_W / 2), horTY, middleBlock.width, middleBlock.height, wallOptions) | |
| ]; | |
| } | |
| buildPockets() { | |
| let pocketOptions = { | |
| render: { fillStyle: 'transparent', lineWidth: 0 }, | |
| label: 'pocket', | |
| isSensor: true | |
| }; | |
| let pocketTopY = WALL_DI * 0.75; | |
| let pocketBottomY = TABLE_H + WALL_DI * 1.25; | |
| let pocketLeftX = WALL_DI * 0.75; | |
| let pocketRightX = TABLE_W + WALL_DI * 1.25; | |
| this.pockets = [ | |
| Bodies.circle(pocketLeftX, pocketTopY, POCKET_RAD, pocketOptions), | |
| Bodies.circle(TABLE_W / 2 + WALL_DI, pocketTopY, POCKET_RAD, pocketOptions), | |
| Bodies.circle(pocketRightX, pocketTopY, POCKET_RAD, pocketOptions), | |
| Bodies.circle(pocketLeftX, pocketBottomY, POCKET_RAD, pocketOptions), | |
| Bodies.circle(TABLE_W / 2 + WALL_DI, pocketBottomY, POCKET_RAD, pocketOptions), | |
| Bodies.circle(pocketRightX, pocketBottomY, POCKET_RAD, pocketOptions) | |
| ]; | |
| } | |
| static get wallVertices() { | |
| let obj = {}; | |
| let quarterW = (TABLE_W - POCKET_RAD * 2) / 4; | |
| let halfH = (TABLE_H - POCKET_RAD) / 2; | |
| obj.bottom = [ | |
| { x: -quarterW, y: WALL_DI }, | |
| { x: quarterW, y: WALL_DI }, | |
| { x: quarterW, y: POCKET_RAD }, | |
| { x: quarterW - POCKET_RAD, y: 0 }, | |
| { x: -quarterW + POCKET_RAD, y: 0 }, | |
| { x: -quarterW, y: POCKET_RAD }, | |
| ]; | |
| obj.top = [ | |
| { x: -quarterW, y: 0 }, | |
| { x: quarterW, y: 0 }, | |
| { x: quarterW, y: WALL_DI - POCKET_RAD }, | |
| { x: quarterW - POCKET_RAD, y: WALL_DI }, | |
| { x: -quarterW + POCKET_RAD, y: WALL_DI }, | |
| { x: -quarterW, y: WALL_DI - POCKET_RAD }, | |
| ]; | |
| obj.left = [ | |
| { y: -halfH, x: 0 }, | |
| { y: halfH, x: 0 }, | |
| { y: halfH, x: WALL_DI - POCKET_RAD }, | |
| { y: halfH - POCKET_RAD, x: WALL_DI }, | |
| { y: -halfH + POCKET_RAD, x: WALL_DI }, | |
| { y: -halfH, x: WALL_DI - POCKET_RAD }, | |
| ]; | |
| obj.right = [ | |
| { y: -halfH, x: WALL_DI }, | |
| { y: halfH, x: WALL_DI }, | |
| { y: halfH, x: POCKET_RAD }, | |
| { y: halfH - POCKET_RAD, x: 0 }, | |
| { y: -halfH + POCKET_RAD, x: 0 }, | |
| { y: -halfH, x: POCKET_RAD }, | |
| ]; | |
| return obj; | |
| } | |
| } | |
| class Machine { | |
| constructor() { | |
| this.clock = 0; | |
| this.fireCount = 0; | |
| this.x = rel(TABLE_W * 0.5); | |
| this.y = rel(TABLE_H * 0.5); | |
| } | |
| reset({ x, y }, placingCueball) { | |
| if (placingCueball) { | |
| this.x = rel(TABLE_W * 0.5); | |
| this.y = rel(TABLE_H * 0.5); | |
| } else { | |
| this.x = x; | |
| this.y = y; | |
| } | |
| this.power = 0; | |
| } | |
| fire() { | |
| if (this.fireCount > 100) { | |
| this.fireCount = 0; | |
| return true; | |
| } | |
| let shouldFire = Math.random() < 0.0125; | |
| if (shouldFire) | |
| this.fireCount = 0; | |
| else | |
| this.fireCount++; | |
| return shouldFire; | |
| } | |
| tick() { | |
| let n1 = noise.perlin2(this.clock, this.clock); | |
| let n2 = noise.perlin2(this.clock + 100, this.clock + 100); | |
| let n3 = noise.perlin2(this.clock + 1000, this.clock + 1000); | |
| let max = 16; | |
| this.x = Math.max(Math.min(this.x + n1 * max, rel(TABLE_W)), rel(0)); | |
| this.y = Math.max(Math.min(this.y + n2 * max, rel(TABLE_H)), rel(0)); | |
| this.clock += 0.02; | |
| this.power = (n3 + 1) * 0.5 * 0.8 + 0.2; | |
| } | |
| } | |
| class Player { | |
| constructor(number) { | |
| this.number = number; | |
| this.stripes = false; | |
| this.solids = false; | |
| this.points = 0; | |
| } | |
| get onEight() { | |
| return this.points === 7; | |
| } | |
| get winner() { | |
| return this.points === 8; | |
| } | |
| get denomText() { | |
| if (this.stripes) return 'Stripes'; | |
| if (this.solids) return 'Solids'; | |
| return ''; | |
| } | |
| get invalidContactText() { | |
| if (this.stripes) return `${this.nameText} did not hit a Stripe first.`; | |
| if (this.solids) return `${this.nameText} did not hit a Solid first.`; | |
| } | |
| get nameText() { | |
| if (this.number === 1) return '<strong>You</strong>'; | |
| return '<strong>AI</strong>'; | |
| } | |
| get eightText() { | |
| return `${this.nameText} Pocketed the Eight.`; | |
| } | |
| get scratchText() { | |
| return `${this.nameText} Scratched!`; | |
| } | |
| get turnText() { | |
| let txt = (this.number === 1) ? 'Your' : 'AI\'s'; | |
| txt = `<strong>${txt}</strong>`; | |
| txt += ' Turn '; | |
| if (this.stripes || this.solids) txt += `(${this.denomText})`; | |
| return txt; | |
| } | |
| get winText() { | |
| if (this.number === 1) return '<strong>You</strong> Win!'; | |
| return '<strong>AI</strong> Wins!'; | |
| } | |
| get teamText() { | |
| return `${this.nameText} is ${this.denomText}`; | |
| } | |
| assign(stripes) { | |
| stripes ? this.stripes = true : this.solids = true; | |
| } | |
| score(count) { | |
| this.points += count; | |
| } | |
| } | |
| class Canvas { | |
| constructor({ context }) { | |
| this.context = context; | |
| } | |
| drawCrosshair({ x, y }) { | |
| this.context.fillStyle = 'rgba(255, 255, 255, 0.25)'; | |
| this.context.beginPath(); | |
| this.context.arc(x, y, BALL_RAD, 0, PI2, false); | |
| this.context.fill(); | |
| } | |
| drawMovingCrosshair({ x, y }) { | |
| let rad = BALL_RAD - 2; | |
| this.context.strokeStyle = COLORS.red; | |
| this.context.lineWidth = 4; | |
| this.context.translate(x, y); | |
| this.context.rotate(-PI * 0.25); | |
| // circle | |
| this.context.beginPath(); | |
| this.context.arc(0, 0, rad, 0, PI2, false); | |
| this.context.stroke(); | |
| // slash | |
| this.context.beginPath(); | |
| this.context.moveTo(0, (BALL_RAD + 2) * -0.5); | |
| this.context.lineTo(0, (BALL_RAD + 2) * 0.5); | |
| this.context.stroke(); | |
| // rotating back | |
| this.context.rotate(PI * 0.25); | |
| this.context.translate(-x, -y); | |
| } | |
| drawTable({ wallBodies, pocketBodies }) { | |
| this.drawSlate(); | |
| this.drawWall(wallBodies); | |
| this.drawReturn(); | |
| this.drawPockets(pocketBodies); | |
| this.drawPoints(); | |
| } | |
| drawSlate() { | |
| let grad = this.context.createRadialGradient( | |
| VIEW_W * 0.5, (VIEW_H - RETURN_H) * 0.5, TABLE_H * 0.75 * 0.125, | |
| VIEW_W * 0.5, (VIEW_H - RETURN_H) * 0.5, TABLE_H * 0.75 * 1.5 | |
| ); | |
| grad.addColorStop(0, 'rgba(255,255,255,0.05)'); | |
| grad.addColorStop(0.25, 'rgba(255,255,255,0.05)'); | |
| grad.addColorStop(1, 'rgba(255,255,255,0.15)'); | |
| this.context.fillStyle = COLORS.felt; | |
| this.context.fillRect(WALL_RAD, WALL_RAD, TABLE_W + WALL_DI, TABLE_H + WALL_DI); | |
| this.context.fillStyle = grad; | |
| this.context.fillRect(WALL_RAD, WALL_RAD, TABLE_W + WALL_DI, TABLE_H + WALL_DI); | |
| } | |
| drawReturn() { | |
| let gutter = (RETURN_H - BALL_DI * 1.2) * 0.5; | |
| this.context.fillStyle = COLORS.pocket; | |
| this.context.fillRect( | |
| gutter, VIEW_H - RETURN_H + gutter, | |
| VIEW_W - gutter * 2, RETURN_H - gutter * 2 | |
| ); | |
| } | |
| drawWall(wallBodies) { | |
| this.context.fillStyle = COLORS.frame; | |
| wallBodies.forEach((body, i) => { | |
| this.context.beginPath(); | |
| body.vertices.forEach(({ x, y }, j) => { | |
| if (j === 0) { | |
| this.context.moveTo(x, y); | |
| } else { | |
| this.context.lineTo(x, y); | |
| } | |
| }); | |
| this.context.fill(); | |
| // BUMPERS | |
| this.context.save(); | |
| this.context.beginPath(); | |
| body.vertices.forEach(({ x, y }, j) => { | |
| if (j === 0) { | |
| this.context.moveTo(x, y); | |
| } else { | |
| this.context.lineTo(x, y); | |
| } | |
| }); | |
| this.context.clip(); | |
| this.context.fillStyle = '#787878'; | |
| let clipOff = WALL_DI * 0.75; | |
| let clipDiff = WALL_DI - clipOff; | |
| this.context.fillRect(clipOff, clipOff, TABLE_W + clipDiff * 2, TABLE_H + clipDiff * 2); | |
| this.context.restore(); | |
| }); | |
| } | |
| drawPockets(pocketBodies) { | |
| this.context.fillStyle = COLORS.pocket; | |
| pocketBodies.forEach(({ position, circleRadius }) => { | |
| this.context.beginPath(); | |
| this.context.arc(position.x, position.y, circleRadius, 0, PI2, false); | |
| this.context.fill(); | |
| }); | |
| } | |
| drawPoints() { | |
| let di = 10, | |
| rad = di * 0.5, | |
| inc = TABLE_W / 7, | |
| xc1 = rel(TABLE_W * 0.25), | |
| xl1 = xc1 - inc, | |
| xr1 = xc1 + inc, | |
| xc2 = xc1 + TABLE_W * 0.5, | |
| xl2 = xc2 - inc, | |
| xr2 = xc2 + inc, | |
| x3 = WALL_RAD * 0.75, | |
| x4 = rel(TABLE_W + WALL_RAD * 1.25), | |
| y1 = WALL_RAD * 0.75, | |
| y2 = rel(TABLE_H + WALL_RAD * 1.25), | |
| yc3 = rel(TABLE_H * 0.5), | |
| yt3 = yc3 - inc, | |
| yb3 = yc3 + inc; | |
| let positions = [ | |
| [xl1, y1], [xc1, y1], [xr1, y1], | |
| [xl1, y2], [xc1, y2], [xr1, y2], | |
| [xl2, y1], [xc2, y1], [xr2, y1], | |
| [xl2, y2], [xc2, y2], [xr2, y2], | |
| [x3, yt3], [x3, yc3], [x3, yb3], | |
| [x4, yt3], [x4, yc3], [x4, yb3] | |
| ]; | |
| this.context.fillStyle = COLORS.brown; | |
| positions.forEach((coords) => { | |
| let x = coords[0], | |
| y = coords[1]; | |
| this.context.beginPath(); | |
| this.context.moveTo(x, y - rad); | |
| this.context.lineTo(x + rad, y); | |
| this.context.lineTo(x, y + rad); | |
| this.context.lineTo(x - rad, y); | |
| this.context.fill(); | |
| }); | |
| } | |
| drawIndicator({ x, y, cueball, power, maxDistance }) { | |
| this.cueX = cueball.position.x; | |
| this.cueY = cueball.position.y; | |
| this.angle = Math.atan2(y - this.cueY, x - this.cueX); | |
| this.angleCos = Math.cos(this.angle); | |
| this.angleSin = Math.sin(this.angle); | |
| // coordinates for starting power just off the cueball | |
| let lineMinX = this.cueX + (BALL_DI * 1.2) * this.angleCos; | |
| let lineMinY = this.cueY + (BALL_DI * 1.2) * this.angleSin; | |
| // coordinates for showing power | |
| let lineMaxX = lineMinX + maxDistance * this.angleCos; | |
| let lineMaxY = lineMinY + maxDistance * this.angleSin; | |
| // coordinates for calculating power | |
| let newX = lineMinX + (power * maxDistance) * this.angleCos; | |
| let newY = lineMinY + (power * maxDistance) * this.angleSin; | |
| // setting the force relative to power | |
| this.forceX = (newX - lineMinX) / maxDistance * 0.02; | |
| this.forceY = (newY - lineMinY) / maxDistance * 0.02; | |
| this.context.lineCap = 'round'; | |
| // max power | |
| this.context.strokeStyle = 'rgba(255, 255, 255, 0.1)'; | |
| this.context.lineWidth = 4; | |
| this.context.beginPath(); | |
| this.context.moveTo(lineMinX, lineMinY); | |
| this.context.lineTo(lineMaxX, lineMaxY); | |
| this.context.stroke(); | |
| this.context.closePath(); | |
| // power level | |
| this.context.strokeStyle = 'rgba(255, 255, 255, 0.9)'; | |
| this.context.lineWidth = 4; | |
| this.context.beginPath(); | |
| this.context.moveTo(lineMinX, lineMinY); | |
| this.context.lineTo(newX, newY); | |
| this.context.stroke(); | |
| this.context.closePath(); | |
| } | |
| drawBalls({ balls, ballIds }) { | |
| let inAngle = []; | |
| for (let i = 0, len = ballIds.length; i < len; i++) { | |
| let ballId = ballIds[i]; | |
| let ball = balls[ballId]; | |
| this.drawBall(ball); | |
| } | |
| } | |
| drawBall(ball) { | |
| let x = ball.body.position.x, | |
| y = ball.body.position.y, | |
| rad = ball.body.circleRadius, | |
| di = rad * 2, | |
| a = ball.body.angle; | |
| this.context.translate(x, y); | |
| this.context.rotate(a); | |
| // offset from center | |
| let offsetX = ((x - WALL_DI) / TABLE_W * 2 - 1), | |
| offsetY = ((y - WALL_DI) / TABLE_H * 2 - 1); | |
| let grad = this.context.createRadialGradient( | |
| rad * offsetX, rad * offsetY, rad * 0.125, | |
| rad * offsetX, rad * offsetY, rad * 1.5 | |
| ); | |
| if (ball.eight) { | |
| grad.addColorStop(0, 'rgba(255,255,255,0.15)'); | |
| grad.addColorStop(1, 'rgba(255,255,255,0.05)'); | |
| } else { | |
| grad.addColorStop(0, 'rgba(0,0,0,0.05)'); | |
| grad.addColorStop(1, 'rgba(0,0,0,0.3)'); | |
| } | |
| this.context.shadowColor = 'rgba(0,0,0,0.05)'; | |
| this.context.shadowBlur = 2; | |
| this.context.shadowOffsetX = -offsetX * BALL_RAD * 0.5; | |
| this.context.shadowOffsetY = -offsetY * BALL_RAD * 0.5; | |
| this.context.fillStyle = ball.color; | |
| this.context.beginPath(); | |
| this.context.arc(0, 0, rad, 0, PI2, false); | |
| this.context.fill(); | |
| this.context.shadowColor = 'transparent'; | |
| if (ball.stripes) { | |
| let s1 = PI * 0.15, | |
| e1 = PI - s1, | |
| s2 = PI * -0.15, | |
| e2 = PI - s2; | |
| this.context.fillStyle = 'white'; | |
| this.context.beginPath(); | |
| this.context.arc(0, 0, rad, s1, e1, false); | |
| this.context.fill(); | |
| this.context.beginPath(); | |
| this.context.arc(0, 0, rad, s2, e2, true); | |
| this.context.fill(); | |
| } | |
| this.context.rotate(-a); | |
| this.context.beginPath(); | |
| this.context.arc(0, 0, rad, 0, PI2, false); | |
| this.context.fillStyle = grad; | |
| this.context.fill(); | |
| this.context.translate(-x, -y); | |
| } | |
| } | |
| class Game { | |
| constructor({ world, canvas, sounds }) { | |
| this.machine = new Machine(); | |
| this.sounds = sounds; | |
| this.world = world; | |
| this.canvas = canvas; | |
| this.$score = document.querySelector('div.score'); | |
| this.$message = document.querySelector('div.message'); | |
| this.table = new Table(); | |
| this.balls = {}; | |
| this.ballIds = []; | |
| this.ballNumbers.forEach(number => { | |
| let ball = new Ball({ number, cueball: this.cueball }); | |
| if (ball.cue) this.cueId = ball.body.id; | |
| if (ball.eight) this.eightId = ball.body.id; | |
| this.balls[ball.body.id] = ball; | |
| this.ballIds.push(ball.body.id); | |
| }); | |
| this.addBodiesToWorld(); | |
| initEscapedBodiesRetrieval(this.ballIds.map(id => this.balls[id].body)); | |
| this.reset(); | |
| } | |
| handleEscapedBall(ballId) { | |
| console.log('ESCAPED', this.balls[ballId]); | |
| this.balls[ballId].reset(); | |
| } | |
| reset() { | |
| this.gameOver = false; | |
| this.break = true; | |
| this.mousedown = false; | |
| this.power = 0; | |
| this.powerStep = 0.015; | |
| this.powerDirection = 1; | |
| this.players = [new Player(1), new Player(2)]; | |
| this.playersAssigned = false; | |
| this.currentPlayerIdx = 0; | |
| this.messages = [this.currentPlayer.turnText]; | |
| this.pocketedThisTurn = []; | |
| this.pocketedStripes = 0; | |
| this.pocketedSolids = 0; | |
| this.placingCueball = true; | |
| this.ballIds.forEach((ballId) => this.balls[ballId].reset()); | |
| this.updateDOM(); | |
| } | |
| get currentPlayer() { | |
| return this.players[this.currentPlayerIdx % 2]; | |
| } | |
| get otherPlayer() { | |
| return this.players[(this.currentPlayerIdx + 1) % 2]; | |
| } | |
| get isMachine() { | |
| return this.currentPlayerIdx % 2 !== 0; | |
| } | |
| addBodiesToWorld() { | |
| World.add(this.world, this.table.bounds); | |
| World.add(this.world, this.table.walls); | |
| World.add(this.world, this.table.pockets); | |
| World.add(this.world, this.ballIds.map(b => this.balls[b].body)); | |
| } | |
| handleMousedown() { | |
| if (this.gameOver) return; | |
| if (this.moving) return; | |
| if (!this.placingCueball) | |
| this.mousedown = true; | |
| } | |
| handleMouseup() { | |
| if (this.gameOver) return; | |
| this.mousedown = false; | |
| if (this.moving) return; | |
| if (this.placingCueball) | |
| this.placeCueball(); | |
| else | |
| this.strikeCueball(); | |
| } | |
| handlePocketed(ballId) { | |
| let ball = this.balls[ballId]; | |
| if (ball.cue) this.setupCueball(); | |
| this.handlePocketedBall(ball); | |
| } | |
| handlePocketedBall(ball) { | |
| this.pocketedThisTurn.push(ball); | |
| let x, | |
| y = VIEW_H - RETURN_H / 2; | |
| if (ball.stripes) { | |
| x = VIEW_W - (this.pocketedStripes * (BALL_DI * 1.2)) - RETURN_H * 0.5; | |
| this.pocketedStripes++; | |
| } else if (ball.solids) { | |
| x = this.pocketedSolids * (BALL_DI * 1.2) + RETURN_H * 0.5; | |
| this.pocketedSolids++; | |
| } else if (ball.cue) { | |
| x = VIEW_W * 0.5 + BALL_RAD * 1.1; | |
| } else { // ball.eight | |
| x = VIEW_W * 0.5 - BALL_RAD * 1.1; | |
| } | |
| ball.pocket({ x, y }); | |
| } | |
| handleTickAfter({ x, y }) { | |
| this.tickPower(); | |
| let power = this.power; | |
| let wasMoving = this.moving; | |
| this.checkMovement(); | |
| let isMoving = this.moving; | |
| if (wasMoving && !isMoving) this.handleTurnEnd(); | |
| let movingCrosshair = { x, y }; | |
| this.canvas.drawTable({ wallBodies: this.table.walls, pocketBodies: this.table.pockets }); | |
| this.canvas.drawBalls({ balls: this.balls, ballIds: this.ballIds }); | |
| let isMachineClick = this.isMachine && this.machine.fire(); | |
| if (this.isMachine) { | |
| this.machine.tick(); | |
| x = this.machine.x; y = this.machine.y; | |
| power = this.machine.power; | |
| } | |
| if (isMachineClick) this.handleMousedown(); | |
| if (this.placingCueball) { | |
| this.moveCueball(x, y); | |
| } else if (!this.moving && !this.gameOver) { | |
| this.canvas.drawIndicator({ | |
| x, y, power, | |
| cueball: this.cueball.body, | |
| maxDistance: this.table.height * 0.5 | |
| }); | |
| } | |
| if (isMachineClick) this.handleMouseup(); | |
| if (isMoving || this.isMachine) this.canvas.drawMovingCrosshair(movingCrosshair); | |
| if (!isMoving) this.canvas.drawCrosshair({ x, y }); | |
| } | |
| handleCollisionActive({ pairs }) { | |
| pairs.forEach(({ bodyA, bodyB }, i) => { | |
| let coll = bodyA.label + bodyB.label; | |
| if (coll === 'ballpocket' || coll == 'pocketball') { | |
| let ball = bodyA.label === 'ball' ? bodyA : bodyB; | |
| let distance = Math.hypot( | |
| bodyA.position.y - bodyB.position.y, | |
| bodyA.position.x - bodyB.position.x | |
| ); | |
| if (distance / BALL_DI <= 1) | |
| this.handlePocketed(ball.id); | |
| } | |
| }); | |
| } | |
| handleCollisionStart({ pairs }) { | |
| if (this.placingCueball) return; | |
| pairs.forEach((collision, i) => { | |
| let { bodyA, bodyB } = collision; | |
| let speed = collision.collision.axisBody.speed; | |
| let coll = bodyA.label + bodyB.label; | |
| if (!this.firstContact && coll === 'ballball') | |
| this.firstContact = [bodyA, bodyB]; | |
| if (coll === 'ballball') { | |
| let vol = Math.min(0.5, speed) + 0.05; | |
| let rate = Math.random() - 0.5 + 1; | |
| this.sounds.ball.rate(rate); | |
| this.sounds.ball.volume(vol); | |
| this.sounds.ball.play(); | |
| } else if (coll === 'ballwall' || coll === 'wallball') { | |
| let vol = Math.min(1, speed) * 0.8 + 0.2; | |
| let rate = Math.random() - 0.5 + 0.75; | |
| this.sounds.rail.rate(rate); | |
| this.sounds.rail.volume(vol); | |
| this.sounds.rail.play(); | |
| } | |
| }); | |
| } | |
| // logic for valid first contact, scoring, and game end. | |
| handleTurnEnd() { | |
| this.restBalls(); | |
| this.messages = []; | |
| this.power = 0; | |
| let pocketed = this.pocketedThisTurn; | |
| let winner = null; | |
| let isCue = pocketed.filter(b => b.cue).length > 0, | |
| isEight = pocketed.filter(b => b.eight).length > 0; | |
| // determining valid first contact | |
| let validFirstContact = true; | |
| if (this.firstContact) { | |
| let balls = this.firstContact.map(b => this.balls[b.id]); | |
| let ball = balls.filter(b => !b.cue)[0]; | |
| if (this.playersAssigned && !isCue && !isEight) | |
| if ( | |
| (this.currentPlayer.stripes && !ball.stripes) || | |
| (this.currentPlayer.solids && !ball.solids) | |
| ) validFirstContact = false; | |
| this.firstContact = null; | |
| } | |
| // handling pocketed balls | |
| if (pocketed.length > 0) { | |
| let stripes = pocketed.filter(b => b.stripes), | |
| solids = pocketed.filter(b => b.solids), | |
| hasStripes = stripes.length > 0, | |
| hasSolids = solids.length > 0; | |
| // assigning players | |
| if (!this.playersAssigned) { | |
| // only assign if one kind of ball went in and cueball and eightball were not pocketed | |
| if ((!hasStripes || !hasSolids) && !isCue && !isEight) { | |
| this.currentPlayer.assign(hasStripes); | |
| this.otherPlayer.assign(!hasStripes); | |
| this.playersAssigned = true; | |
| } | |
| } | |
| // calculate scores | |
| if (this.currentPlayer.stripes) { | |
| this.currentPlayer.score(stripes.length); | |
| this.otherPlayer.score(solids.length); | |
| } else if (this.currentPlayer.solids) { | |
| this.currentPlayer.score(solids.length); | |
| this.otherPlayer.score(stripes.length); | |
| } | |
| // handling game over | |
| if (isEight) { | |
| this.messageEight(); | |
| winner = (this.currentPlayer.onEight) ? this.currentPlayer : this.otherPlayer; | |
| // handling cueball | |
| } else if (isCue) { | |
| this.messageScratch(); | |
| this.switchTurns(); | |
| // handling invalid contact | |
| } else if (!validFirstContact) { | |
| this.messageInvalidContact(); | |
| this.switchTurns(); | |
| // handling the wrong ball | |
| } else if ( | |
| (!hasStripes && this.currentPlayer.stripes) || | |
| (!hasSolids && this.currentPlayer.solids) | |
| ) { | |
| this.switchTurns(); | |
| } | |
| // scratching with no other pocketed balls | |
| } else if (isCue) { | |
| this.messageScratch(); | |
| this.switchTurns(); | |
| // switching turns on nothing going in | |
| } else { | |
| this.switchTurns(); | |
| } | |
| // ending the turn | |
| this.pocketedThisTurn = []; | |
| if (winner) { | |
| this.messageWin(winner); | |
| this.handleGameOver(); | |
| } else { | |
| this.messageTurn(); | |
| } | |
| if (this.isMachine) { | |
| let aMachineBall = this.aMachineBall; | |
| this.machine.reset(aMachineBall.body.position, this.placingCueball); | |
| } | |
| this.updateDOM(); | |
| } | |
| handleGameOver() { | |
| this.gameOver = true; | |
| let $button = document.createElement('button'); | |
| $button.innerHTML = 'New Game'; | |
| $button.addEventListener('click', () => { | |
| $button.remove(); | |
| this.reset(); | |
| }); | |
| document.body.appendChild($button); | |
| } | |
| messageTurn() { | |
| this.messages.push(this.currentPlayer.turnText); | |
| } | |
| messageScratch() { | |
| this.messages.push(this.currentPlayer.scratchText); | |
| } | |
| messageInvalidContact() { | |
| this.messages.push(this.currentPlayer.invalidContactText); | |
| } | |
| messageEight() { | |
| this.messages.push(this.currentPlayer.eightText); | |
| } | |
| messageWin(winner) { | |
| this.messages.push(winner.winText); | |
| } | |
| restBalls() { | |
| this.ballIds.forEach(id => this.balls[id].rest()); | |
| } | |
| strikeCueball() { | |
| this.break = false; | |
| this.moving = true; | |
| let power = this.isMachine ? this.machine.power : this.power; | |
| let vol = Math.min(1, power) * 0.9 + 0.1; | |
| this.sounds.cue.volume(vol); | |
| this.sounds.cue.play(); | |
| Body.applyForce( | |
| this.cueball.body, | |
| this.cueball.body.position, | |
| { x: this.canvas.forceX, y: this.canvas.forceY } | |
| ); | |
| } | |
| setupCueball() { | |
| this.cueball.disable(); | |
| this.placingCueball = true; | |
| } | |
| placeCueball() { | |
| this.cueball.enable(); | |
| this.cueball.pocketed = false; | |
| this.placingCueball = false; | |
| } | |
| moveCueball(x, y) { | |
| if (this.moving) { | |
| x = rel(TABLE_W / 2); | |
| y = rel(TABLE_H + WALL_DI + RETURN_H * 0.5); | |
| } else { | |
| let maxX = (this.break) ? rel(TABLE_W / 4 - BALL_RAD) : rel(TABLE_W - BALL_RAD), | |
| minX = rel(0 + BALL_RAD), | |
| maxY = rel(TABLE_H - BALL_RAD), | |
| minY = rel(0 + BALL_RAD); | |
| x = Math.min(maxX, Math.max(minX, x)); | |
| y = Math.min(maxY, Math.max(minY, y)); | |
| } | |
| this.cueball.setVelocity({ x: 0, y: 0 }); | |
| Body.setPosition(this.cueball.body, { x, y }); | |
| } | |
| tickPower() { | |
| if (this.mousedown) { | |
| this.power += this.powerStep * this.powerDirection; | |
| if (this.power < 0) { | |
| this.powerDirection = 1; | |
| this.power = 0; | |
| } else if (this.power > 1) { | |
| this.powerDirection = -1; | |
| this.power = 1; | |
| } | |
| } | |
| } | |
| updateDOM() { | |
| let current = this.currentPlayerIdx % 2; | |
| this.$score.innerHTML = | |
| this.updatePlayerDOM(this.players[0], current === 0) + | |
| this.updatePlayerDOM(this.players[1], current === 1); | |
| this.$message.innerHTML = '<p>' + this.messages.map(m => m).join(' ') + '</p>'; | |
| } | |
| updatePlayerDOM(player, current) { | |
| return `<span> | |
| <span>${player.nameText}</span> | |
| <span>${player.points}</span> | |
| </span>`; | |
| } | |
| switchTurns() { | |
| this.currentPlayerIdx++; | |
| } | |
| checkMovement() { | |
| if (this.moving) { | |
| let moving = false; | |
| for (let i = 0, len = this.ballIds.length; i < len && !moving; i++) { | |
| let ballId = this.ballIds[i]; | |
| let ball = this.balls[ballId]; | |
| if (ball.body && ball.body.speed > 0.125) moving = true; | |
| } | |
| this.moving = moving; | |
| } | |
| } | |
| get aMachineBall() { | |
| let balls = this.ballIds.map(id => this.balls[id]).filter(b => !b.pocketed); | |
| if (this.players[1].onEight) { | |
| balls = [this.eightball]; | |
| } else if (this.players[1].stripes) { | |
| balls = balls.filter(b => b.stripes); | |
| } else if (this.players[1].solids) { | |
| balls = balls.filter(b => b.solids); | |
| } else { | |
| balls = balls.filter(b => !b.cue && !b.eight); | |
| } | |
| return balls[Math.floor(Math.random() * balls.length)]; | |
| } | |
| get cueball() { | |
| return this.balls[this.cueId]; | |
| } | |
| get eightball() { | |
| return this.balls[this.eightId]; | |
| } | |
| get ballNumbers() { | |
| return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; | |
| } | |
| } | |
| // create a world and engine | |
| let world = World.create({ gravity: { x: 0, y: 0 } }); | |
| let engine = Engine.create({ world, timing: { timeScale: 1 } }); | |
| // create a renderer | |
| let element = document.querySelector('div.canvas'); | |
| let render = Render.create({ | |
| element, engine, | |
| options: { | |
| width: VIEW_W, | |
| height: VIEW_H, | |
| wireframes: WIREFRAMES, | |
| background: COLORS.frame | |
| } | |
| }); | |
| if (window.location.href.match(/cpgrid/)) { | |
| document.body.classList.add('screenshot'); | |
| let src = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/111863/billiards.png'; | |
| let img = new Image(); | |
| img.src = src; | |
| document.body.appendChild(img); | |
| } else { | |
| let canvas = new Canvas(render); | |
| let mouse = Mouse.create(render.canvas); | |
| let sounds = { | |
| cue: new Howl({ src: [ASSET_PREFIX + 'billiards-cue.mp3', ASSET_PREFIX + 'billiards-cue.ogg'] }), | |
| ball: new Howl({ src: [ASSET_PREFIX + 'billiards-ball.mp3', ASSET_PREFIX + 'billiards-ball.ogg'] }), | |
| rail: new Howl({ src: [ASSET_PREFIX + 'billiards-rail.mp3', ASSET_PREFIX + 'billiards-rail.ogg'] }) | |
| }; | |
| let game = new Game({ world, canvas, sounds }); | |
| Events.on(render, 'afterRender', () => { | |
| game.handleTickAfter({ x: mouse.position.x, y: mouse.position.y }); | |
| }); | |
| let constraint = MouseConstraint.create(engine, { mouse }); | |
| Events.on(constraint, 'mousedown', ({ mouse }) => { | |
| game.handleMousedown(); | |
| }); | |
| Events.on(constraint, 'mouseup', ({ mouse }) => { | |
| game.handleMouseup(); | |
| }); | |
| Events.on(engine, 'collisionActive', (e) => { | |
| game.handleCollisionActive({ pairs: e.pairs }); | |
| }); | |
| Events.on(engine, 'collisionStart', (e) => { | |
| game.handleCollisionStart({ pairs: e.pairs }); | |
| }); | |
| // run the engine | |
| Engine.run(engine); | |
| // run the renderer | |
| Render.run(render); | |
| } | |
| function rel(x) { | |
| return x + WALL_DI; | |
| } | |
| function initEscapedBodiesRetrieval(allBodies) { | |
| function hasBodyEscaped(body) { | |
| let { x, y } = body.position; | |
| return x < 0 || x > VIEW_W || y < 0 || y > VIEW_H; | |
| } | |
| setInterval(() => { | |
| let i, body; | |
| for (i = 0; i < allBodies.length; i++) { | |
| body = allBodies[i]; | |
| if (hasBodyEscaped(body)) game.handleEscapedBall(body.id); | |
| } | |
| }, 300); | |
| } |
| <script src="https://cdn.rawgit.com/schteppe/poly-decomp.js/1ef946f1/build/decomp.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.12.0/matter.min.js"></script> | |
| <script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/111863/perlin.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.0.3/howler.min.js"></script> |
| @import url('https://fonts.googleapis.com/css?family=Share+Tech+Mono'); | |
| html, body { height: 100%; } | |
| body { | |
| background-color: #292929; | |
| background-image: radial-gradient(circle at center, #292929 0, #121212 100%); | |
| cursor: crosshair; | |
| font-family: 'Share Tech Mono', sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| font-size: 1.2rem; | |
| @media (min-width: 600px) { | |
| font-size: 1.4rem; | |
| } | |
| @media (min-width: 800px) { | |
| font-size: 1.8rem; | |
| } | |
| @media (min-width: 1200px) { | |
| font-size: 2.2rem; | |
| } | |
| } | |
| $div-background: #121212; | |
| $border-color: #121212; | |
| $border-size: 8px; | |
| img { | |
| position: absolute; | |
| width: auto; | |
| height: auto; | |
| max-width: 95%; | |
| max-height: 95%; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| } | |
| button { | |
| appearance: none; | |
| font-family: 'Share Tech Mono', sans-serif; | |
| font-size: 2rem; | |
| font-weight: 400; | |
| background: #121212; | |
| color: white; | |
| border-radius: 4px; | |
| padding: 0.5rem 1rem; | |
| position: absolute; | |
| border: none; | |
| cursor: pointer; | |
| box-shadow: 0px 2px 0px 3px #212121; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| &:hover { | |
| box-shadow: 0px 0px 0px 3px #212121; | |
| top: calc(50% + 2px); | |
| } | |
| } | |
| body.screenshot section { display: none; } | |
| section { | |
| position: absolute; | |
| width: 1200px; | |
| max-width: calc(100% - 2rem); | |
| max-width: calc(100% - 4rem); | |
| top: 50%; left: 50%; | |
| transform: translate3d(-50%, -50%, 0); | |
| display: flex; | |
| flex-direction: column; | |
| div.score, | |
| div.message { | |
| color: white; | |
| line-height: 1; | |
| text-transform: uppercase; | |
| font-weight: 300; | |
| width: 100%; | |
| flex-basis: 50%; | |
| background: $div-background; | |
| box-sizing: border-box; | |
| border: $border-size solid $border-color; | |
| } | |
| div.score { | |
| order: 1; | |
| display: flex; | |
| font-size: 1em; | |
| > span { | |
| width: 50%; | |
| text-align: center; | |
| display: flex; | |
| align-content: space-between; | |
| justify-content: space-between; | |
| padding: 0.5rem 1rem; | |
| span:first-child { padding: 0.25em 0; } | |
| span:last-child { | |
| color: $div-background; | |
| background: #f0f0f0; | |
| border-radius: 2px; | |
| padding: 0.25em 0.25em; | |
| } | |
| &:last-child { | |
| border-left: $border-size solid $border-color; | |
| span:first-child { order: 2 } | |
| span:last-child { order: 1 } | |
| } | |
| } | |
| } | |
| div.message { | |
| order: 3; | |
| text-align: center; | |
| font-size: 0.8em; | |
| padding: 0.5rem; | |
| p { | |
| margin: 0; | |
| } | |
| } | |
| div.canvas { | |
| order: 2; | |
| flex-basis: 100%; | |
| width: 100%; | |
| box-sizing: border-box; | |
| border-left: $border-size solid $border-color; | |
| border-right: $border-size solid $border-color; | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: auto; | |
| cursor: none; | |
| } | |
| } | |
| } |