Popular game development zero foundation · Season 6 - common programming frameworks and algorithms

01.MVC architecture

It is an idea / routine of designing programs

Meaning of MVC

  1. M-Model (data)
    The location of the bird in FlappyBird, the currently active node, and so on
  2. V-View visual layer
    The displayed interface is usually processed by the engine
  3. C-Controller (logic)
    Write down the code, the bird meets the tube to judge the end of the game

Summary connotation: take data - according to logic - refresh interface

02. Singleton mode

Characteristics of single instance

  1. A singleton class has only one object globally, and it is impossible to generate multiple objects
  2. This object is easily accessible anywhere in the code

effect

Prevent frequent implementation and destruction of a globally used class
Control the number of instances and save system resources

realization

/**Singleton class */
export default class InstanceDemo {
    /**Globally unique object */
    private static _instance: InstanceDemo = null; //private variable names are usually preceded by_

    /**Get singleton object */
    public static getInstance(): InstanceDemo{
        if (InstanceDemo._instance == null){
            InstanceDemo._instance = new InstanceDemo();
        }
        return InstanceDemo._instance;
    }

    /**Prevent the creation of a second object */
    constructor(){
        if (InstanceDemo._instance != null) {
            throw Error("Already has InstanceDemo._instance");
        }
    }

    num: number = 0;

    trace(){
        this.num ++;
        console.log("trace",this.num);
    }
}

call

import InstanceDemo from "./instanceDemo";

const {ccclass, property} = cc._decorator;

@ccclass
export default class HelloWorld extends cc.Component {

    start () {
        /**Get singleton object */
        let instance = InstanceDemo.getInstance();
        /**Call singleton method */
        instance.trace();

        //create object
        new InstanceDemo();
    }

    // update (dt) {}
}

result

supplement

Implement the singleton pattern and privatize the constructor

/**Privatized constructor*/
private constructor(){}

03. Observer mode - subscription publishing mode

technological process

  1. A subscribed to the start game event
  2. B throws (releases) the start game event
  3. A response event

Features: A is only responsible for accepting the event when it is triggered no matter when it starts. B does not know who registered the event and is only responsible for triggering

realization

Change WebStorm to ES6:File → Setting → Languages & framework → JavaScript → select ES6
Also add typescriptconfig Change "ES5" in JSON to "ES6"

EventCenter.ts event control center
EventHandler records event information

/**Observer mode */
export default class EventCenter {
    //The event data stores the event name and the registration information of the event
    private static events: Map<string,Array<EventHandler>> = new Map<string,Array<EventHandler>>();

    /**Registration event
     * eventName: string Event name
     * target: object Who registers the event for callback binding
     * callBack: Function Callback
     */
    static registerEvent(eventName: string,target: object,callBack: Function): void {
        if (eventName == undefined || target == undefined || callBack == undefined) {
            throw Error("regsiter event error");
        }
        /**Judge whether this event has been registered */
        if (EventCenter.events[eventName] == undefined){
            EventCenter.events[eventName] = new Array<EventHandler>();
        }

        /**Store the information of this registration event in the Map */
        let handler = new EventHandler(target,callBack);
        EventCenter.events[eventName].push(handler);
    }

    /**Trigger event
     * eventName: string Event name
     * param?: any Callback Arguments 
     */
    static postEvent(eventName: string,param?: any): void {
        let handlers = EventCenter.events[eventName];
        if (handlers == undefined){
            return;
        }

        //Traverse all eventhandlers registered with the event
        for (let i = 0; i < handlers.length; i++){
            let handler = handlers[i];
            if (handler){
                //Call event callback
                //Use try catch to prevent the callback from reporting an error but no information
                try {
                    //. call (bound this, parameter) calls the method
                    handler.function.call(handler.target,param);
                }catch (e){
                    console.log(e.message);
                    console.log(e.stack.toString()); //Output stack information
                }
            }
        }
    }

    /**Remove registration event
     * eventName: string Event name
     * target: object Who registers the event for callback binding
     * callBack: Function Register event callback
     */
    static removeEvent(eventName: string,target: object,callBack: Function): void {
        if (eventName == undefined || target == undefined || callBack == undefined) {
            throw Error("destory event failed");
        }
        let handlers = EventCenter.events[eventName];
        if (handlers){
            for (let i = 0; i < handlers.length; i++){
                let handler = handlers[i];
                if (handler && target == handler.target && callBack == handler.function){
                    //There are two removal methods "= undefined" has better performance and "split" needs to save memory space
                    handlers[i] = undefined;
                    // handlers.splice(i,1);
                    break;
                }
            }
        }
    }

}

/**Registration information class */
class EventHandler {
    /**Record who registered the event */
    target: object;
    /**The method called when the logging event is triggered */
    function: Function;

    constructor(target: object,func: Function){
        this.target = target;
        this.function = func;
    }
}

Panel.ts is used to register events

import EventCenter from "./EventCenter";

/**The observer pattern used to register event validation */
const {ccclass, property} = cc._decorator;

@ccclass
export default class Panel extends cc.Component {

    @property(cc.Label)
    label: cc.Label = null;

    onLoad () {
        EventCenter.registerEvent("gameStart",this,this.onGameStart);
        //Remove event registration after 5s
        this.scheduleOnce(function() {
            this.onDestroy();
        }.bind(this),5)
    }

    /**Register event callback */
    onGameStart(str: string){
        console.log("event callBack");
        this.label.string = str;
    }

    onDestroy(){
        //Remove gameStart event
        console.log("remove event gameStart");
        EventCenter.removeEvent("gameStart",this,this.onGameStart);
    }
}

Trigger event

EventCenter.postEvent("gameStart","game is start!");

result


Click the button


output

Note: after the target node is destroyed, remember to unregister the event. Otherwise, callBack will make an error

04. Factory mode

Characteristics and functions

The operation object itself only realizes the function method, and the specific operation is realized by the factory
This does not expose objects and creation logic

realization

/**Factory mode */

//c: {new(): t} tells ide that C of type T can be instantiated
export function createAttack<T extends IActor>(c: {new (): T},life: number): T {
    let object = new c();
    object.attack();
    object.life = life;
    return object;
}

export function createDie<T extends IActor>(c: {new (): T}): T {
    let object = new c();
    object.die();
    return object;
}

/**Role interface */
interface IActor {
    attack: Function;
    die: Function;
    life: number;
}

/**Thieves */
export class Thief implements IActor {
    life: number;
    attack() {
        console.log("thief attack");
    }
    die() {
        console.log("thief is die");
    }
}

/**warrior */
export class Warrior implements IActor {
    life: number;
    attack() {
        console.log("warrior attack");
    }
    die() {
        console.log("warrior is die");
    }
}

call

	createAttack<Thief>(Thief,10);
    createDie<Warrior>(Warrior);

05. Agency mode

Agent (additional controller)

  1. Single principle, do not give a class too many functions
    Its own functions are left in the class and some logical controls are put outside
    High cohesion and low coupling
  2. When it is inconvenient to access a class, give a proxy
    For example: the relationship between stars and agents;
    In express delivery, consignor - Express - consignee;
    cc. loader. The inside of load (...) is very complex, but the caller doesn't care about the internal logic

06. Recursive pathfinding

subject

You need to walk from ① to ②, and how to walk (you can walk obliquely, but Brown can't walk)

step

  1. Take the starting point as the current node
  2. Repeat the following steps
    a. Add the current node to the openList and mark it Open
    b. Find the next node to go
    c. Sort the nodes that can be taken in the next step
    d. Take the point closest to the end point that can be taken in the next step as the current wayfinding node
    e. Mark the nodes passed as Close
  3. Until the target node is found

actual combat

First make the grid nodegrid ts

import FindPath from "./FindPath";

/**Wayfinding map grid */
const {ccclass, property} = cc._decorator;
/**Grid display layer */
@ccclass
export default class NodeGrid extends cc.Component {
    dataGrid: DataGrid = null;
    findPathController: FindPath;

    onLoad () {
        this.node.on(cc.Node.EventType.TOUCH_END,this.onBtnGrid,this);
    }

    /**Click the grid to determine the starting point and end point to generate the route */
    onBtnGrid(){
        this.findPathController.onTouch(this);
    }

    /**Refresh grid color */
    updateGridColor(){
        if (this.dataGrid.type == GrideType.Normal){
            this.node.color = new cc.Color().fromHEX("#fffff9");
        } else if (this.dataGrid.type == GrideType.Wall){
            this.node.color = new cc.Color().fromHEX("#151513");
        } else if (this.dataGrid.type == GrideType.Road){
            this.node.color = new cc.Color().fromHEX("#41ff0b");
        } else {
            this.node.color = new cc.Color().fromHEX("#fff42d");
        } 
    }
}

/**Grid data layer */
export class DataGrid {
    type: GrideType;
    //coordinate
    x: number;
    y: number;
    /**Is it the current node */
    inOpenList: boolean = false;
    /**Path node tag */
    inCloseList: boolean = false;
}

/**Lattice type enumeration */
export enum GrideType {
    Normal, //ordinary
    Wall, //wall
    Start, //Starting point, current node
    End, //End
    Road, //Route
}

Create a 40x40 grid in the scene and mount nodegrid ts

Making maps

/**Generate 8x8 map randomly */
    generateMap () {
        for (let x = 0;x < 8;x ++){
            this.dataGrids[x] = [];
            this.nodeGrids[x] = [];
            for (let y = 0;y < 8;y ++){
                let rand = Math.random();
                let grideType: GrideType = GrideType.Normal;
                if (rand < 0.2) { //1 / 5 probability generated wall
                    grideType = GrideType.Wall;
                }
                //Data layer
                let grid: DataGrid = new DataGrid();
                grid.x = x;
                grid.y = y;
                grid.type = grideType;
                this.dataGrids[x][y] = grid;

                //View layer
                let gridNode: NodeGrid = cc.instantiate(this.nodeGridPrefab).getComponent(NodeGrid);
                gridNode.node.position = cc.v3(50 * (x - 4),50 * (y - 4),0);
                this.nodeGrids[x][y] = gridNode;
                gridNode.dataGrid = grid;
                gridNode.findPathController = this;
                gridNode.updateGridColor();
                gridNode.node.parent = this.node;
            }
        }
    }

Pathfinding (full code FindPath.ts)

import NodeGrid, { DataGrid, GrideType } from "./NodeGrid";

/**Recursive pathfinding */
const {ccclass, property} = cc._decorator;

@ccclass
export default class NewClass extends cc.Component {
    /**Lattice node */
    @property(cc.Node)
    nodeGridPrefab: cc.Node = null;

    dataGrids: DataGrid[][] = [];
    nodeGrids: NodeGrid[][] = [];

    /**Record starting point */
    startGrid: DataGrid = null;
    /**End point record */
    endGrid: DataGrid = null;

    onLoad () {
        this.generateMap();
    }

    /**Generate 8x8 map randomly */
    generateMap () {
        for (let x = 0;x < 8;x ++){
            this.dataGrids[x] = [];
            this.nodeGrids[x] = [];
            for (let y = 0;y < 8;y ++){
                let rand = Math.random();
                let grideType: GrideType = GrideType.Normal;
                if (rand < 0.2) { //1 / 5 probability generated wall
                    grideType = GrideType.Wall;
                }
                //Data layer
                let grid: DataGrid = new DataGrid();
                grid.x = x;
                grid.y = y;
                grid.type = grideType;
                this.dataGrids[x][y] = grid;

                //View layer
                let gridNode: NodeGrid = cc.instantiate(this.nodeGridPrefab).getComponent(NodeGrid);
                gridNode.node.position = cc.v3(50 * (x - 4),50 * (y - 4),0);
                this.nodeGrids[x][y] = gridNode;
                gridNode.dataGrid = grid;
                gridNode.findPathController = this;
                gridNode.updateGridColor();
                gridNode.node.parent = this.node;
            }
        }
    }

    /**Click grid */
    onTouch(nodeGrid: NodeGrid){
        if (!this.startGrid) { //Set start point
            this.startGrid = nodeGrid.dataGrid;
            this.startGrid.type = GrideType.Start;
            nodeGrid.updateGridColor();
        }else if (!this.endGrid) { //Set end point
            this.endGrid = nodeGrid.dataGrid;
            this.endGrid.type = GrideType.End;
            nodeGrid.updateGridColor();

            //Pathfinding
            this.startFindPath();
        }
    }

    openPath: DataGrid[] = [];

    /**Pathfinding */
    startFindPath(){
        if (this.find(this.startGrid)) {
            for (let i = 0; i < this.openPath.length; i++) {
                let path = this.openPath[i];
                path.type = GrideType.Road;
                this.nodeGrids[path.x][path.y].updateGridColor();
            }
        }else {
            console.log("Unable to reach the end");
        }
    }

    find(base: DataGrid) {
        this.openPath.push(base);
        base.inOpenList = true;
        if (base == this.endGrid){ //End of pathfinding
            return true;
        }
        let round = this.getRoundGrid(base);
        for (let i = 0;i < round.length;i ++) {
            let nextBaseGride = round[i];
            if (this.find(nextBaseGride)) {
                return true;
            }
        }

        base.inCloseList = true;
        this.openPath.splice(this.openPath.length - 1,1);
        return false;
    }

    /**Gets the walkable nodes around the current node */
    getRoundGrid(grid: DataGrid): DataGrid[] {
        let arr: DataGrid[] = [];
        //Surrounding grid
        this.addToRoundIfNeed(arr,this.getGrid(grid.x,grid.y + 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x - 1,grid.y));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x,grid.y - 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x + 1,grid.y));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x - 1,grid.y - 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x + 1,grid.y + 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x + 1,grid.y - 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x - 1,grid.y + 1));

        //It will compare the elements in the array in pairs. If - 1 is returned in the user-defined method, the exchange position will not be returned, and 1 exchange position will be returned
        arr.sort(this.compareGrids.bind(this));

        return arr;
    }

    /**Put the grid in the array */
    addToRoundIfNeed(arr: DataGrid[],roundGrid: DataGrid) {
        //Current node and path node are not included
        if (!roundGrid || roundGrid.type == GrideType.Wall || roundGrid.inCloseList || roundGrid.inOpenList){
            return;
        }
        if (roundGrid) {
            arr.push(roundGrid);
        }
    }

    /**Obtain grid data according to coordinates */
    getGrid(x: number,y: number): DataGrid {
        //Boundary judgment
        if (x < 0 || x >= 8 || y < 0 || y >=8){
            return null;
        }
        return this.dataGrids[x][y];
    }

    /**Distance between grid comparison and end point
     * Put the one with a small distance in front
     */
    compareGrids(grid0: DataGrid,grid1: DataGrid): number{
        let grid0Dis = this.getDistance(grid0);
        let grid1Dis = this.getDistance(grid1);
        if (grid0Dis > grid1Dis) {
            return 1;
        }else{
            return -1;
        }
    }

    /**Get the distance from the node to the end point */
    getDistance(grid: DataGrid){
        return Math.abs(grid.x - this.endGrid.x) + Math.abs(grid.y - this.endGrid.y);
    }

    /**Click restart*/
    onBtnRestart(){
        for (let x = 0;x < this.dataGrids.length;x ++){
            for (let y = 0;y < this.dataGrids[x].length;y ++){
                let dataGrid = this.dataGrids[x][y];
                dataGrid.inOpenList = false;
                dataGrid.inCloseList = false;
                if (dataGrid.type != GrideType.Wall){
                    dataGrid.type = GrideType.Normal;
                }
                this.nodeGrids[x][y].updateGridColor();
            }
        }
        this.startGrid = null;
        this.endGrid = null;
        this.openPath = [];
    }

}

Operation results

Insufficient

The current algorithm only looks for the optimal solution of the next step, not the global optimal solution, sometimes not the optimal path.

07.A star pathfinding

You can read it written by the great God Principle of star A routing algorithm

step

  1. Take the starting point as the current node
  2. Repeat the following steps
    a. Add the current node to the openList and mark it Open
    b. Find the next node to go
    c. Set the parent node of the next node as the current node
    d. Add the nodes that can go next to openList and sort them
    e. Take the first node in openList as the current node
    f. Mark the nodes passed as Close
  3. Until the target node is found

realization

Add a field to the DataGrid

/**The parent node is used for star A routing */
fatherGrid: DataGrid = null;

FindPathAX.ts complete code

/**A Star pathfinding */
import NodeGrid, { DataGrid, GrideType } from "./NodeGrid";

const {ccclass, property} = cc._decorator;

@ccclass
export default class FindPathAX extends cc.Component {
    /**Lattice node */
    @property(cc.Node)
    nodeGridPrefab: cc.Node = null;

    dataGrids: DataGrid[][] = [];
    nodeGrids: NodeGrid[][] = [];

    /**Record starting point */
    startGrid: DataGrid = null;
    /**Record end point */
    endGrid: DataGrid = null;

    onLoad () {
        this.generateMap();
    }

    /**Generate 8x8 map randomly */
    generateMap () {
        for (let x = 0;x < 8;x ++){
            this.dataGrids[x] = [];
            this.nodeGrids[x] = [];
            for (let y = 0;y < 8;y ++){
                let rand = Math.random();
                let grideType: GrideType = GrideType.Normal;
                if (rand < 0.2) { //1 / 5 probability generated wall
                    grideType = GrideType.Wall;
                }
                //Data layer
                let grid: DataGrid = new DataGrid();
                grid.x = x;
                grid.y = y;
                grid.type = grideType;
                this.dataGrids[x][y] = grid;

                //View layer
                let gridNode: NodeGrid = cc.instantiate(this.nodeGridPrefab).getComponent(NodeGrid);
                gridNode.node.position = cc.v3(50 * (x - 4),50 * (y - 4),0);
                this.nodeGrids[x][y] = gridNode;
                gridNode.dataGrid = grid;
                gridNode.findPathController = this;
                gridNode.updateGridColor();
                gridNode.node.parent = this.node;
            }
        }
    }

    /**Click grid */
    onTouch(nodeGrid: NodeGrid){
        if (!this.startGrid) { //Set start point
            this.startGrid = nodeGrid.dataGrid;
            this.startGrid.type = GrideType.Start;
            nodeGrid.updateGridColor();
        }else if (!this.endGrid) { //Set end point
            this.endGrid = nodeGrid.dataGrid;
            this.endGrid.type = GrideType.End;
            nodeGrid.updateGridColor();

            //Pathfinding
            this.startFindPathAStar();
        }
    }

    /**List of nodes to be considered */
    openPath: DataGrid[] = [];

    /**A Star pathfinding */
    startFindPathAStar(){
        this.openPath.push(this.startGrid);
        this.startGrid.inOpenList = true;

        while (this.openPath.length > 0) {
            let current = this.openPath.shift(); //shift -- take out the first element in the array

            if (current == this.endGrid) {
                break;
            }
            let round = this.getRoundGrid(current);

            for (let i = 0;i < round.length;i ++) {
                let r = round[i];
                r.fatherGrid = current;
                r.inOpenList = true;
            }

            this.openPath = this.openPath.concat(round); //Splice array
            this.openPath.sort(this.compareGridsAStar.bind(this));

            current.inCloseList = true;
        }

        if (this.endGrid.fatherGrid) {
            let pathGrid = this.endGrid;

            while (pathGrid) {
                pathGrid.type == GrideType.Road;
                this.nodeGrids[pathGrid.x][pathGrid.y].updateGridColor();
                pathGrid = pathGrid.fatherGrid;
            }
        }else {
            console.log("There is no path to go");
        }
    }

    /**Gets the walkable nodes around the current node */
    getRoundGrid(grid: DataGrid): DataGrid[] {
        let arr: DataGrid[] = [];
        //Surrounding grid
        this.addToRoundIfNeed(arr,this.getGrid(grid.x,grid.y + 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x - 1,grid.y));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x,grid.y - 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x + 1,grid.y));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x - 1,grid.y - 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x + 1,grid.y + 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x + 1,grid.y - 1));
        this.addToRoundIfNeed(arr,this.getGrid(grid.x - 1,grid.y + 1));

        //It will compare the elements in the array in pairs. If - 1 is returned in the user-defined method, the exchange position will not be returned, and 1 exchange position will be returned
        arr.sort(this.compareGridsAStar.bind(this));

        return arr;
    }

    /**Put the grid in the array */
    addToRoundIfNeed(arr: DataGrid[],roundGrid: DataGrid) {
        //Current node and path node are not included
        if (!roundGrid || roundGrid.type == GrideType.Wall || roundGrid.inCloseList || roundGrid.inOpenList){
            return;
        }
        if (roundGrid) {
            arr.push(roundGrid);
        }
    }

    /**Obtain grid data according to coordinates */
    getGrid(x: number,y: number): DataGrid {
        //Boundary judgment
        if (x < 0 || x >= 8 || y < 0 || y >=8){
            return null;
        }
        return this.dataGrids[x][y];
    }

    /**Lattice sorting optimization
     * Put the one with a small distance in front
     */
     compareGridsAStar(grid0: DataGrid,grid1: DataGrid): number{
        let grid0Dis = this.getDistanceAStar(grid0,this.startGrid,this.endGrid);
        let grid1Dis = this.getDistanceAStar(grid1,this.startGrid,this.endGrid);
        if (grid0Dis > grid1Dis) {
            return 1;
        }else{
            return -1;
        }
    }

    /**Obtain comprehensive distance optimization
     * grid Current node
     * start Starting node
     * end Target node
    */
    getDistanceAStar(grid: DataGrid,start: DataGrid,end: DataGrid) {
        let endDis = Math.abs(grid.x - end.x) + Math.abs(grid.y - end.y);
        let startDis = Math.abs(grid.x - start.x) + Math.abs(grid.y - start.y);
        return endDis + startDis;
    }

    /**Click restart*/
    onBtnRestart(){
        for (let x = 0;x < this.dataGrids.length;x ++){
            for (let y = 0;y < this.dataGrids[x].length;y ++){
                let dataGrid = this.dataGrids[x][y];
                dataGrid.inOpenList = false;
                dataGrid.inCloseList = false;
                if (dataGrid.type != GrideType.Wall){
                    dataGrid.type = GrideType.Normal;
                }
                this.nodeGrids[x][y].updateGridColor();
            }
        }
        this.startGrid = null;
        this.endGrid = null;
        this.openPath = [];
    }

}


08. Object pool mode

significance

It is used to avoid repeated creation and save energy when a large number of identical objects need to be created

flow chart

Create objects directly

/**Object pool mode */
const {ccclass, property} = cc._decorator;

@ccclass
export default class PoolDemo extends cc.Component {

    @property(cc.Node)
    nodeIcon: cc.Node = null;

    onLoad () {

    }

    shoot() {
        let node = cc.instantiate(this.nodeIcon);
        //It's too cumbersome to move out of the parent node after creating the node
        node.runAction(cc.sequence(cc.moveBy(1,0,300),cc.removeSelf()));
        node.parent = this.node;
    }

    update() {
        this.shoot();
    }
}

It will constantly create nodes and move them out of the parent node, consuming performance and memory

Use object pool

/**Object pool mode */
const {ccclass, property} = cc._decorator;

@ccclass
export default class PoolDemo extends cc.Component {

    @property(cc.Node)
    nodeIcon: cc.Node = null;

    /**Object pool */
    pool: cc.Node[] = [];

    onLoad () {

    }

    shoot() {
        let node = this.getNode();
        //It's too cumbersome to move out of the parent node after creating the node
        node.runAction(cc.sequence(cc.moveBy(1,0,300),cc.removeSelf(),cc.callFunc(function () {
            node.position = cc.Vec2.ZERO; //Node position reset
            //Put the node back into the object pool after use
            this.pool.push(node);
        }.bind(this))));
        node.parent = this.node;
    }

    /**Get node
     * If there are nodes in the object pool, take them out for use
     * If not, instantiate one
     */
    getNode(): cc.Node {
        if (this.pool.length > 0) {
            return this.pool.shift();
        }else {
            console.log("Created a node");
            return cc.instantiate(this.nodeIcon);
        }
    }

    update() {
        this.shoot();
    }
}

Operation effect

Only 62 nodes need to be created

Added by mxdan on Mon, 31 Jan 2022 11:14:53 +0200