├── README.md ├── utils.js ├── world.js ├── index.html └── index.js /README.md: -------------------------------------------------------------------------------- 1 | # Start 2 | > you need node (npm) installed 3 | 4 | Run the following command in project root folder: 5 | ``` 6 | npx http-server 7 | ``` 8 | 9 | To change layout or controls visit configuration on the top left. 10 | 11 | Change control: Press a button, then the new key. 12 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | import rxjs from 'https://dev.jspm.io/rxjs@6'; 2 | import operators from 'https://dev.jspm.io/rxjs@6/operators'; 3 | 4 | const { fromEvent } = rxjs; 5 | const { startWith, map, tap, switchMap, shareReplay, take } = operators; 6 | 7 | const list = (length, fn) => Array.from({ length }, (_, i) => fn(i)); 8 | 9 | const capNumber = (min, max, number) => Math.max(min, Math.min(max, number)); 10 | 11 | const getInputNumberStream = element => fromEvent(element, 'input').pipe( 12 | map(({ target }) => target.value), 13 | startWith(element.value), 14 | map(val => Number(val)), 15 | ); 16 | 17 | const getButtonClickStream = element => fromEvent(element, 'click').pipe( 18 | tap(event => event.preventDefault() && event.stopPropagation()), 19 | ); 20 | 21 | const selectButtonFromKeyEventStream = (element, stream) => getButtonClickStream(element).pipe( 22 | switchMap(_ => stream.pipe(take(1))), 23 | map(({ key }) => key), 24 | tap(key => element.textContent = key), 25 | startWith(element.textContent), 26 | shareReplay(1), 27 | ); 28 | 29 | const renderAsText = (observable, element) => observable.subscribe(value => element.textContent = String(value)); 30 | 31 | export { 32 | list, 33 | capNumber, 34 | getInputNumberStream, 35 | getButtonClickStream, 36 | selectButtonFromKeyEventStream, 37 | renderAsText 38 | } -------------------------------------------------------------------------------- /world.js: -------------------------------------------------------------------------------- 1 | import { list, capNumber } from './utils.js'; 2 | 3 | const GROUND = 0; 4 | const PLAYER = 1; 5 | const BOULDER = 2; 6 | 7 | const generateWorld = (dimX, dimY) => list(dimY, () => list(dimX, i => Math.random() < 0.1 ? BOULDER : GROUND )); 8 | 9 | const updateWorld = (world, { event, payload }) => { 10 | switch (event) { 11 | case 'MovePlayer': 12 | const { player, worldMap } = world; 13 | const [ deltaX, deltaY ] = payload; 14 | 15 | // reset player render 16 | worldMap[player.y][player.x] = 0; 17 | 18 | // cap with world boundaries 19 | player.x = capNumber(0, worldMap[0].length - 1, player.x + deltaX); 20 | player.y = capNumber(0, worldMap.length - 1, player.y + deltaY); 21 | 22 | // set player render 23 | worldMap[player.y][player.x] = 1; 24 | 25 | return { worldMap, player }; 26 | default: 27 | return world; 28 | } 29 | }; 30 | 31 | const renderWorld = ([[{worldMap}, length], { x, y }, ctx]) => { 32 | // clear canvas 33 | ctx.clearRect(0,0, window.innerWidth, window.innerHeight); 34 | 35 | // draw world 36 | worldMap.forEach((worldSlice, i) => { 37 | const yPos = y + length * i; 38 | // console.log('yPos:', yPos); 39 | worldSlice.forEach((field, j) => { 40 | const xPos = x + length * j; 41 | // console.log('xPos:', xPos); 42 | switch (field) { 43 | case GROUND: 44 | ctx.fillStyle = 'green'; 45 | break; 46 | case PLAYER: 47 | ctx.fillStyle = 'red'; 48 | break; 49 | case BOULDER: 50 | ctx.fillStyle = 'brown'; 51 | break; 52 | default: 53 | ctx.fillStyle = 'black'; 54 | } 55 | ctx.fillRect(xPos, yPos, length, length); 56 | }) 57 | }); 58 | }; 59 | 60 | export { 61 | generateWorld, 62 | updateWorld, 63 | renderWorld 64 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 28 | 29 | 30 |
31 | Configuration 32 |
33 | 34 | 35 |
36 | 43 |
44 | 45 |
46 | 53 |
54 |
55 | 56 | 57 |
58 | 65 |
66 | 67 |
68 | 75 |
76 |
77 | 78 |
79 | 80 |
81 |
82 | 83 |
84 | 85 |
86 |
87 | 88 |
89 | 90 |
91 |
92 | 93 |
94 | 95 |
96 |
97 |

98 | 99 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import rxjs from 'https://dev.jspm.io/rxjs@6'; 2 | import operators from 'https://dev.jspm.io/rxjs@6/operators'; 3 | 4 | import { generateWorld, updateWorld, renderWorld } from './world.js'; 5 | import { getInputNumberStream, selectButtonFromKeyEventStream, renderAsText } from './utils.js'; 6 | 7 | const { fromEvent, BehaviorSubject, combineLatest, merge, NEVER } = rxjs; 8 | const { tap, share, startWith, scan, switchMap, withLatestFrom, map, shareReplay } = operators; 9 | 10 | // sources 11 | const canvasContext$ = new BehaviorSubject(canvas.getContext('2d')); 12 | 13 | const startX$ = getInputNumberStream(startX); 14 | 15 | const startY$ = getInputNumberStream(startY); 16 | 17 | const dimX$ = getInputNumberStream(dimX).pipe( 18 | tap(val => { 19 | startX.max = String(val - 1); 20 | if (Number(startX.value) > Number(startX.max)) { 21 | startX.value = startX.max; 22 | startX.dispatchEvent(new Event('input')); 23 | } 24 | }) 25 | ); 26 | 27 | const dimY$ = getInputNumberStream(dimY).pipe( 28 | tap(val => { 29 | startY.max = String(val - 1); 30 | if (Number(startY.value) > Number(startY.max)) { 31 | startY.value = startY.max; 32 | startY.dispatchEvent(new Event('input')); 33 | } 34 | }) 35 | ); 36 | 37 | const resize$ = fromEvent(window, 'resize').pipe( 38 | startWith(undefined), 39 | shareReplay(1) 40 | ); 41 | 42 | const keydown$ = fromEvent(document, 'keydown').pipe( 43 | share() 44 | ); 45 | 46 | const configurationOpen$ = fromEvent(configuration, 'toggle').pipe( 47 | map(({ target }) => target.open), 48 | startWith(configuration.open), 49 | shareReplay(1) 50 | ); 51 | 52 | // intermediates 53 | const controlKey$ = configurationOpen$.pipe( 54 | switchMap(open => open ? NEVER : keydown$) 55 | ); 56 | 57 | const configurationKey$ = configurationOpen$.pipe( 58 | switchMap(open => open ? keydown$ : NEVER) 59 | ); 60 | 61 | const moveUpKey$ = selectButtonFromKeyEventStream(moveUp, configurationKey$); 62 | const moveDownKey$ = selectButtonFromKeyEventStream(moveDown, configurationKey$); 63 | const moveLeftKey$ = selectButtonFromKeyEventStream(moveLeft, configurationKey$); 64 | const moveRightKey$ = selectButtonFromKeyEventStream(moveRight, configurationKey$); 65 | 66 | const dimension$ = combineLatest( 67 | dimX$, 68 | dimY$ 69 | ).pipe( 70 | map(([x, y]) => ({ x, y })), 71 | shareReplay(1) 72 | ); 73 | 74 | const playerStartPosition$ = combineLatest( 75 | startX$, 76 | startY$ 77 | ).pipe( 78 | map(([x, y]) => ({ x, y })), 79 | shareReplay(1) 80 | ); 81 | 82 | const canvasCenter$ = resize$.pipe( 83 | tap(_ => (canvas.width = window.innerWidth) && (canvas.height = window.innerHeight)), 84 | map(_ => ({ x: window.innerWidth / 2, y: window.innerHeight / 2 })), 85 | ); 86 | 87 | const canvasFieldLength$ = combineLatest( 88 | resize$, 89 | dimension$ 90 | ).pipe( 91 | map(([_, { x, y }]) => Math.floor(Math.min(window.innerWidth / x, window.innerHeight / y))), 92 | shareReplay(1) 93 | ); 94 | 95 | const canvasWorldStart$ = canvasFieldLength$.pipe( 96 | withLatestFrom(dimension$, canvasCenter$), 97 | map(([length, { x: dimX, y: dimY }, { x, y }]) => ({ 98 | x: Math.round(x - length * dimX / 2), 99 | y: Math.round(y - length * dimY / 2) 100 | })) 101 | ); 102 | 103 | const initWorld$ = combineLatest( 104 | dimension$, 105 | playerStartPosition$ 106 | ).pipe( 107 | map(([{ x: dimX, y: dimY }, { x, y }]) => { 108 | 109 | const worldMap = generateWorld(dimX, dimY); 110 | worldMap[y][x] = 1; 111 | return { worldMap, player: { x, y } }; 112 | }) 113 | ); 114 | 115 | const playerPositionDelta$ = controlKey$.pipe( 116 | withLatestFrom( 117 | moveUpKey$, 118 | moveDownKey$, 119 | moveLeftKey$, 120 | moveRightKey$ 121 | ), 122 | map(([{ key }, moveUp, moveDown, moveLeft, moveRight]) => { 123 | switch (key) { 124 | case moveUp: 125 | return { event: 'MovePlayer', payload: [0, -1]}; 126 | case moveDown: 127 | return { event: 'MovePlayer', payload: [0, +1]}; 128 | case moveLeft: 129 | return { event: 'MovePlayer', payload: [-1, 0]}; 130 | case moveRight: 131 | return { event: 'MovePlayer', payload: [+1, 0]}; 132 | default: 133 | return { event: 'MovePlayer', payload: [0, 0] }; 134 | } 135 | }), 136 | ); 137 | 138 | const updateWorld$ = initWorld$.pipe( 139 | switchMap(initialWorld => merge( 140 | playerPositionDelta$ 141 | ).pipe( 142 | scan(updateWorld, initialWorld), 143 | startWith(initialWorld) 144 | )), 145 | ); 146 | 147 | const render$ = combineLatest( 148 | updateWorld$, 149 | canvasFieldLength$ 150 | ).pipe( 151 | withLatestFrom( 152 | canvasWorldStart$, 153 | canvasContext$ 154 | ), 155 | tap(renderWorld) 156 | ); 157 | 158 | const keyName$ = keydown$.pipe( 159 | map(({ key }) => key) 160 | ); 161 | 162 | // sinks 163 | renderAsText(keyName$, lastPressed); 164 | render$.subscribe(); 165 | --------------------------------------------------------------------------------