Last active
July 7, 2022 04:36
-
-
Save stevekrouse/fe7a9aaa881df66793b48786158a3756 to your computer and use it in GitHub Desktop.
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
import {run} from '@cycle/run'; | |
import {makeCanvasDriver, rect, text} from 'cycle-canvas'; | |
import { makeKeyboardDriver } from 'cycle-keyboard' | |
import onionify from 'cycle-onionify'; | |
import {makeCollection} from 'cycle-onionify'; | |
import isolate from '@cycle/isolate' | |
import xs from 'xstream' | |
import fromEvent from 'xstream/extra/fromEvent' | |
import split from 'xstream/extra/split' | |
import throttle from 'xstream/extra/throttle' | |
import concat from 'xstream/extra/concat' | |
import _ from 'lodash' | |
import collide from 'box-collide'; | |
const CANVAS_WIDTH = window.innerWidth - 3 | |
const CANVAS_HEIGHT = window.innerHeight - 5 | |
const BIRD_WIDTH = 100 | |
const BIRD_HEIGHT = 100 | |
const BIRD_X = 100 | |
const TIME_DELTA = 10 // TODO this needs to be in more constants | |
const GRAVITY = -0.01 / TIME_DELTA | |
const JUMP_SPEED = 0.5 | |
const TIME_FROM_JUMP_TO_TOP_OF_ARC = JUMP_SPEED / GRAVITY | |
const JUMP_TIME = 2 * TIME_FROM_JUMP_TO_TOP_OF_ARC | |
const NEXT_JUMP_TIME = JUMP_TIME * 0.5 | |
const STARTING_Y_POSITION = -200 | |
const NEW_PIPE_TIME = 2000 | |
const PIPE_SPEED = 2 | |
const PIPE_GAP = 300 | |
const PIPE_HEIGHT = (CANVAS_HEIGHT - PIPE_GAP) / 2 | |
const PIPE_WIDTH = 100 | |
const INITIAL_STATE = { | |
bird: { | |
y: STARTING_Y_POSITION, | |
ySpeed: 0 | |
}, | |
score: 0, | |
pipes: [], | |
gameOver: false | |
} | |
function Bird({action$, onion: {state$}}) { | |
return { | |
onion: action$.map(action => function updateReducer(prevState) { | |
if (action === "TICK" && !prevState.gameOver) { | |
const touchingTopOfScreen = prevState.bird.y > 0 | |
const touchingBottomOfScreen = prevState.bird.y - BIRD_HEIGHT < -CANVAS_HEIGHT | |
const outOfBounds = touchingTopOfScreen || touchingBottomOfScreen | |
const gameOver = prevState.gameOver || outOfBounds | |
return { | |
gameOver: gameOver, | |
bird: { | |
y: prevState.bird.y + (prevState.bird.ySpeed * TIME_DELTA), | |
ySpeed: prevState.bird.ySpeed + (GRAVITY * TIME_DELTA) | |
} | |
} | |
} | |
else if (action === "JUMP") { | |
return { | |
...prevState, | |
bird: { | |
...prevState.bird, | |
ySpeed: JUMP_SPEED | |
} | |
} | |
} else { | |
return prevState | |
} | |
}), | |
canvas: state$.map(state => ( | |
rect({ | |
x: BIRD_X, | |
y: -state.bird.y, // negative because the canvas y is upside down | |
width: BIRD_WIDTH, | |
height: BIRD_HEIGHT, | |
draw: [{fill: "orange"}] | |
}) | |
)) | |
} | |
} | |
function Pipe({action$, onion: {state$}}) { | |
return { | |
onion: action$.map(action => function updateReducer(prevState) { | |
if (action === "TICK" && !prevState.gameOver) { | |
if (prevState.x < BIRD_X && !prevState.pastBird) { | |
return {...prevState, score: prevState.score + 1, pastBird: true} | |
} else if (prevState.x < -200) { | |
return undefined | |
} else { | |
return {...prevState, x: prevState.x - PIPE_SPEED} | |
} | |
} else { | |
return prevState | |
} | |
}), | |
canvas: state$.map(({x, yOffset}) => [ | |
rect({ | |
x: x, | |
y: 0, | |
width: PIPE_WIDTH, | |
height: PIPE_HEIGHT + yOffset, | |
draw: [{fill: "green"}] | |
}), | |
rect({ | |
x: x, | |
y: PIPE_HEIGHT + PIPE_GAP + yOffset, | |
width: PIPE_WIDTH, | |
height: CANVAS_HEIGHT, | |
draw: [{fill: "green"}] | |
}) | |
]) | |
} | |
} | |
function GameOverText({action$, onion: {state$}}) { | |
return { | |
canvas: state$.map(gameOver => [text({ | |
x: (CANVAS_WIDTH / 2) - 230, | |
y: (CANVAS_HEIGHT / 2) + 10, | |
value: gameOver ? "Game Over" : "", | |
font: '70pt Arial', | |
draw: [{fill: 'white'}] | |
})]) | |
} | |
} | |
function ScoreText({action$, onion: {state$}}) { | |
return { | |
canvas: state$.map(score => [text({ | |
x: (CANVAS_WIDTH / 2) - 90, | |
y: 50, | |
value: "Score: " + score, | |
font: '40pt Arial', | |
draw: [{fill: 'white'}] | |
})]) | |
} | |
} | |
function main(sources) { | |
const {action$, onion: {state$}} = sources | |
// Bird | |
const birdLens = { | |
get: state => ({bird: state.bird, gameOver: state.gameOver}), | |
set: (state, childState) => ({...state, bird: childState.bird, gameOver: childState.gameOver}) | |
}; | |
const birdSinks = isolate(Bird, {onion: birdLens})(sources); | |
const birdReducer$ = birdSinks.onion | |
const birdCanvas$ = birdSinks.canvas | |
// Pipes | |
const pipesLens = { | |
get: state => state.pipes.map(pipe => { return {...pipe, gameOver: state.gameOver, score: state.score}}), | |
set: (state, pipes) => ({ | |
...state, | |
pipes: pipes, | |
score: _.maxBy(pipes, 'score').score | |
}) | |
}; | |
const PipesList = makeCollection({ | |
item: Pipe, | |
itemKey: (childState, index) => String(index), | |
itemScope: key => key, | |
collectSinks: instances => { | |
return { | |
onion: instances.pickMerge('onion'), | |
canvas: instances.pickCombine('canvas').map(_.flatten) | |
} | |
} | |
}) | |
const pipesSinks = isolate(PipesList, {onion: pipesLens})(sources) | |
const pipesReducer$ = pipesSinks.onion | |
const pipesCanvas$ = pipesSinks.canvas | |
// Game Over Text | |
const gameOverTextSinks = isolate(GameOverText, 'gameOver')(sources); | |
const gameOverTextCanvas$ = gameOverTextSinks.canvas | |
// Score Text | |
const scoreTextSinks = isolate(ScoreText, 'score')(sources); | |
const scoreTextCanvas$ = scoreTextSinks.canvas | |
// Onionify State Reducer | |
const initReducer$ = xs.of(_ => INITIAL_STATE) | |
const updateReducer$ = action$.map(action => function updateReducer(prevState) { | |
if (prevState.gameOver && action === "JUMP") { | |
return INITIAL_STATE | |
} else if (action === "NEW_PIPE" && !prevState.gameOver) { | |
return { | |
...prevState, | |
pipes: prevState.pipes.concat({ | |
x: CANVAS_WIDTH + 200, | |
yOffset: (Math.random() * 200) - 100 | |
}) | |
} | |
} else if (action === "TICK" && !prevState.gameOver) { | |
// Detect Collision | |
const pipeIntersectingBird = pipe => { | |
const bounds = {x: pipe.x, width: PIPE_WIDTH} | |
const topPipe = {...bounds, y: 0, height: PIPE_HEIGHT + pipe.yOffset} | |
const bottomPipe = {...bounds, y: PIPE_HEIGHT + pipe.yOffset + PIPE_GAP, height: PIPE_HEIGHT} | |
const bird = {x: BIRD_X, y: -prevState.bird.y, width: BIRD_WIDTH, height: BIRD_HEIGHT} | |
return collide(topPipe, bird) || collide(bottomPipe, bird) | |
} | |
if (prevState.pipes.some(pipeIntersectingBird)) { | |
return { | |
...prevState, | |
gameOver: true | |
} | |
} else { | |
return prevState | |
} | |
} else { | |
return prevState | |
} | |
}); | |
const parentReducer$ = xs.merge(initReducer$, updateReducer$); | |
const reducer$ = xs.merge(birdReducer$, pipesReducer$, parentReducer$) | |
// Canvas Rendering | |
const canvas$ = xs.combine(birdCanvas$, pipesCanvas$, gameOverTextCanvas$, scoreTextCanvas$) | |
.map(components => rect({draw: [{fill: 'skyblue'}]}, _.flatten(components))) | |
return { | |
canvas: canvas$, | |
onion: reducer$ | |
}; | |
} | |
const wrappedMain = onionify(main); | |
const click$ = fromEvent(document, 'mousedown') | |
const spaceKey$ = fromEvent(document, 'keypress').filter(key => key.keyCode == 32) | |
const jump$ = xs.merge(spaceKey$, click$).compose(throttle(NEXT_JUMP_TIME)).mapTo("JUMP") | |
const tick$ = xs.periodic(TIME_DELTA).mapTo("TICK") | |
const newPipe$ = xs.periodic(NEW_PIPE_TIME).mapTo("NEW_PIPE") | |
const action$ = xs.merge(jump$, tick$, newPipe$) | |
const drivers = { | |
canvas: makeCanvasDriver(null, {width: CANVAS_WIDTH, height: CANVAS_HEIGHT}), | |
action$: () => action$ | |
}; | |
run(wrappedMain, drivers); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment