Skip to content

Instantly share code, notes, and snippets.

@stevekrouse
Last active July 7, 2022 04:36
Show Gist options
  • Save stevekrouse/fe7a9aaa881df66793b48786158a3756 to your computer and use it in GitHub Desktop.
Save stevekrouse/fe7a9aaa881df66793b48786158a3756 to your computer and use it in GitHub Desktop.
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