diff --git a/README.md b/README.md index b2538ce483561ca5ad82d64ceed2f5dd42a4309d..e370eb1d335b4a19199a2fecc59732513bce5ce1 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ export interface GameConfig { The next task I would suggest is just creating this object and rendering it to a little UI using your chosen framework. That'll let you fiddle around with the layout. -Then, wire up the functionality. Note that you'll probably need to change `life`. At the moment, it's a constant in that file. You will probably want it to be a variable that can be set by calling a function. +Then, wire up the functionality. Note that you'll probably need to change `life`. At the moment, it's a constant in the render file. You will probably want it to be a variable that can be set by calling a function. ## Solving this with Vue @@ -270,7 +270,7 @@ template: ` <input type="number" v-model:value="config.w" /> Rows: - <input type="number" v-model:value="config.w" /> + <input type="number" v-model:value="config.h" /> </div> `, ``` @@ -301,7 +301,7 @@ Reload and check that logs to the browser console when you hit the button (and t We need to let our component actually configure our `Life` instance. -First, in `life.ts`, let's turn `life` into a variable, and also add a create function that accepts a `GameConfig` and a function for getting the value of life. +First, in `render.ts`, let's turn `life` into a variable, and also add a create function that accepts a `GameConfig` and a function for getting the value of life. ```ts import { GameConfig } from "./gameConfig" @@ -388,3 +388,298 @@ Period: ``` Notice this button is bound so it's disabled until we've created the game. It's onClick is bound to the `toggleTimer` method, and the text changes from Start to Stop depending on whether the timer is already started. + + +## Solving this tutorial with React + +There's a worked solution to this tutorial using React.js on the `react-solution` branch. But let's talk you through it. + +### Some config changes + +First, there's some config changes we'll make to make things easier. By default, React won't give you nice error messages -- it left them out of the library to save space. So make the HTML load the development scripts from our node_modules directory: + +```html +<script src="./node_modules/react/umd/react.development.js"></script> +<script src="./node_modules/react-dom/umd/react-dom.development.js"></script> +``` + +And tell webpack not to put React itself into bundle.js (because we're loading its development scripts separately). In webpack.config.js, inside `module.exports`: + +```js +externals: { + "react": "React", + "react-dom": "ReactDOM" +} +``` + +We're also going to want to use TSX, so let's update compilerOptions in `tsconfig.json`: + +```js + "jsx": "react" +``` + +And let's also rename `index.ts` to `index.tsx`, and then update `webpack.congfig.js` to tell it about the new entry point: + +```js + entry: './src/index.tsx', +``` + +### Getting React on-screen + +Let's get React rendering something, even if just Hello World. + +In index.html, create a div that Vue can render to. Let's put this above the svg for the game: + +```html +<div id="app"></div> +``` + +And inside index.tsx, let's import React and ReactDOM, and something to that div + +```ts +import * as React from "react"; +import * as ReactDOM from "react-dom"; +``` + +and now for our first bit of TSX: + +```ts +ReactDOM.render( + <div>Hello React</div>, + document.getElementById("app") +); +``` + +Set webpack watching the files: + +```sh +npm run build -- --watch +``` + +Reload index.html in the browser, and check we get "Hello Vue" appearing. If so, good -- React is working and we can progress. + +### Creating a ConfigView component + +Let's create a file called `gameConfig.tsx'. + +First, let's import react: + +```tsx +import * as React from "react" +import * as ReactDOM from "react-dom" +``` + +Then let's define two types -- one for the config's definable properties, and another that has some additional state: + +```tsx +export interface ConfigProps { + w:number + h:number +} +``` + +```tsx +export interface ConfigState extends ConfigProps { + created:boolean + period:number + intervalId?: number +} +``` + +intervalId is optional -- hence the `?` + +And then let's define a React component: + +```tsx +export class ConfigView extends React.Component<ConfigProps, ConfigState> { + render() { + return <div>This will render some game config</div> + } + + constructor(props:ConfigProps) { + super(props) + this.state = { + ...props, + created: false, + period: 500 + } + } + +} +``` + +Notice that in the angle brackets, we've said this component's properties are defined by the ConfigProps interface -- so, it has properties for `w` and`h`. +And we've said its state is defined by the `ConfigState` interface. + +In the constructor, we've also taken the component's properties, and augmented them with the missing compulsory variables. `...props` is the "spread" operator that sets all the name-value pairs from +`props` on the new object we're creating. + +Let's go back to `index.tsx` and use our component. + +First, we need to import `ConfigView`: + +```tsx +import { ConfigView } from "./gameConfig" +``` + +and then let's alter our React call to use it: + +```tsx +ReactDOM.render( + <ConfigView w={40} h={20}></ConfigView>, + document.querySelector("#app") +); +``` + +Notice that we've been able to set the `w` and `h` properties as attributes on the component. This sets them in the `props` object. + +Reload and see if the message is in the UI. + +### Render some controls + +It's time to get our ConfigView to render some real controls. Let's alter `gameConfig.tsx`. + +```tsx + render() { + return <div> + Cols: <input type="number" value={this.state.w} onChange={(e) => this.setW(+e.target.value)} /> + Rows: <input type="number" value={this.state.h} onChange={(e) => this.setH(+e.target.value)} /> + <button onClick={(e) => this.create()}>Create</button> + </div> + } +``` + +In this, we've bound the input fields to render the `w` and `h` fields from the current state. + +We've then needed to bind an `onChange` handler for each field. This takes a lambda function. +The parameter `(e)` is the event that has happened. And we're getting `e.target.value` from it to get the number out +of the input field. This comes out as a string, even though the input field is for numbers. So in order to convert it +to a number, we use `+e.target.value`. This then needs to get passed to `this.setW` and `this.setH` -- methods on our class. So we'd better write those! + +Likewise, we've boud the button's click handler to `this.create()`. + +Let's write `setW` and `setH`, which are methods on the `ConfigView` class: + +```tsx + setW(w:number) { + this.setState((state:ConfigState) => { + state.w = w + return state + }) + } + + setH(h:number) { + this.setState((state:ConfigState) => { + state.h = h + return state + }) + } +``` + +In both cases, we call `setState`, which is a method from the React API (remember, `ConfigView` inherits from `React.Component`). `setState` gives you the current state, and asks you to return the new state. It will then automatically trigger re-rendering the component. + +Now let's write `create` method, and just get it to log to the console: + +```tsx + create() { + console.log("I was clicked") + } +``` + +Reload the page, and check the code so far works + +### Making it create the Life game + +Let's alter `render.ts`. + +First, import the ConfigProps interface + +```ts +import { ConfigProps } from "./gameConfig" +``` + +and now let's make `life` a variable instead of a constant. We'll initialise it to a zero-sized game so it won't be null but won't be visible on the screen. And we'll define a `create` method that takes ConfigProps. + +```ts +let life = new Life(0, 0) +``` + +```ts +function create(config:ConfigProps) { + life = new Life(config.w, config.h) +} +``` + +We'd best make sure those are exported: + +```ts +export { + life as life, + render as render, + create as create +} +``` + +Now let's go back to `gameConfig.tsx` and make it set up the game by altering what happens when we click the button + +First, let's import the functions from render.ts + +```tsx +import { life, create, render} from "./render" +``` + +Then let's alter the `create` method on `ConfigView`: + +```tsx + create() { + create(this.state) // note this calls the function imported from render.ts + render() // note this calls the function imported from render.ts + } +``` + +Reload, and check it works + + +### Setting up the timer + +The last part we'll do is setting up the timer. In `gameConfig.tsx`, let's alter the render() method for our component: + +```tsx + render() { + return <div> + Cols: <input type="number" value={this.state.w} onChange={(e) => this.setW(+e.target.value)} /> + Rows: <input type="number" value={this.state.h} onChange={(e) => this.setH(+e.target.value)} /> + <button onClick={(e) => this.create()}>Create</button> + + Period: <input type="number" value={this.state.period} onChange={(e) => this.setPeriod(+e.target.value)} /> + <button onClick={(e) => this.toggleTimer()}>{ this.state.intervalId ? "Stop" : "Start" }</button> + </div> +``` + +We've added an input field, very similar to the ones for `w` and `h` but operating on `period`. + +We've also added a button that will call `toggleTimer`, which we need to define. Its text is set by a ternary expression on whether `intervalId` in the component's state is defined. + +`toggleTimer` can work as another call to `setState`. Only this time, the call is also going to either start or cancel a JavaScript timer. We need to use `window.setInterval` because in Node setInterval has a different return type than in the browser -- calling `window.setInterval` makes sure TypeScript uses the browser's version. + +```ts + toggleTimer() { + this.setState((state:ConfigState) => { + + if (state.intervalId) { + clearInterval(state.intervalId) + state.intervalId = undefined + return state + } else { + state.intervalId = window.setInterval(() => { + life.stepGame() + render() + }, this.state.period) + return state + } + + }) + } +``` + +Reload, and see if it all works! \ No newline at end of file