【JavaScript】ブラウザだけでエレベータープログラミングゲームで楽しく学ぶ【無料】


エレベータープログラミングゲーム
Elevator Saga

快適な移動を支援する。

普段エレベータの動きに不満はないですか?

自分なら改善できるのにと思っているなら、
自分で考えてみましょう。

ハッカーのゲーム Hacknet がSteamにありますが
今回紹介するこれは
エレベーターの動きをプログラミングするゲームです。

思ってたものより動きはシンプルですが
動かそうとすると相当複雑でした。

※ Saga とは英雄とか、歴史的神話の登場人物みたいなニュアンスです。

プログラミング言語

JavaScriptで遊べます。

どんな感じ?

全18ラウンド+エンドレスモード

stage 6,13あたりが乗り越えられればいけるかも。

ランダム要素が強いので、 10回ぐらいやれば1回ぐらいクリアできることもあります。

Web上でコードを書き、アニメーション付きで表示されます。
コードは自動でセーブされ、ローカルストレージに保存されます。色分け機能もついてます。

シミュレーションスピードも簡単に変えることができます。

プログラミングに相当慣れた中学生ぐらいでないと全クリアは難しいかもしれません。
ただ、4,5面ぐらいまでは、普通にクリアして楽しめると思います。

ステージは、制限時間タイプ、動作量制限タイプ、待ち時間制限タイプなどがあります。

次のページでブラウザさえあれば、すぐ動かせて遊べますよ☺
Elevator Saga - the elevator programming game
https://play.elevatorsaga.com/

登場するオブジェクト

ステージごとに、(大きさも変わる)エレベータとフロアのオブジェクトがあり、
それぞれ、特定のイベント(ボタンが押された、止まった)が来た時の動作をプログラミングしていきます。

人はランダムで発生し、重さがあるようです。
人はフロア0 (イギリス方式?)に、降りるときよく向かいます。

フィールド:レッツプレイ

Elevator Saga - the elevator programming game
https://play.elevatorsaga.com/

APIマニュアルや、ソース(GitHub), wikiなども完備されてます。

Web検索すると解決サンプルを上げている人もいます。

後ろのステージを見たい場合
https://play.elevatorsaga.com/#challenge=8

初めのソース

初期化(init)のところを注目します。
プログラムの動き初めに実行される内容です。

ここのエレベータオブジェクトに、行き先を登録しておけば、起動後はその動作が行われます。
今回は以下のように フロア先を設定することでその階に移動します。
(イギリスでは入り口をエントランスと認識しており、
日本でいう2階から、1階が始まるようになってます)

最初の例は、行き先が完了した時(idleイベント)、実行するプログラムです。

            // let's go to all the floors (or did we forget one?)
            elevator.goToFloor(0);
            elevator.goToFloor(1);

この例では、2フロアに行けません。そうするには・・・?

初期の改善のヒント

1.エレベータが複数台になったらどうすればいいでしょうか?
elevatorsが配列なので、ループすれば各オブジェクトが取れます。 floorsも同様です。
初期サンプルのように、それぞれのエレベータにイベントを登録します。

2.ボタンが押されたら直ちに直行するには?

elevator と floorの ボタン押下イベントを活用します。

3.上手くいったならば、人がいないところに留まってもしょうがないので、スキップしたいですよね。
これが結構大変だと思います。

stop イベントを活用します。

注意点

  • 満員を完全に検知することはできません(空いていても、満員かどうかわかりません。重さは検知できる。男、女性、子供がいる)
  • ドアが閉じるイベント(そのタイミングでの動作設定ができない)はありません。
  • エレベータが動き出してから、乗った人がボタン押すこともあります。
  • 一度押され、ONになったボタンは、次の人が押すことはありません(状態は変化しないので覚えておき、次のボタン押下などのイベントで適切に判断できるようにする)。
  • (行き先をクリアすると、elevatorのアイドルイベントが割り込むことがあります)

後忘れた、、、
(意外とできないことがあって、相当細かい方針を変えた・・)

※Traffic Confusionとかでないかな?

ソース

大方針

エレベータの動きはラウンド方式で、端までの距離で往復を繰り返します。
(人がいなければスキップ)
そのため、例えば上がり切るまでなど、UP/DOWNの方向性を保持するように意識します。
行き先の保持は1floorだけで、1フロア移動するごとに計算しなおしています。

後細かくは、ボタンが押されたら、都度反応するようにしています。

このプログラムは、stage 6,7,13あたりをまれ(1/10回?)にクリアします。

L. 200行ちょっとになるとは思っていたけれども、400行ほどになってしまいました。

{
    init: function(elevators, floors) {
        var stopFloorUpping = [];
        var stopFloorDowning = [];
        var elevatorProgress = [];
        var elevatorState = [];//0:stop  1:move 2: aimlessly //useless?
        var elevatorStopState = []; // id,0 : before Prograss , id:1 : before stop floor, id,2: wait (useless?)
        //round movement
        var stopFloorsFlow = [0,1,2,3,4,5,6,7,8,9,10
                              ,11,12,13,14,15,16,17,18,19,20
                              ,19,18,17,16,15,14,13,12,11,10
                              ,9,8,7,6,5,4,3,2,1];
        var maxFloor = 1;
        var debugPrint = false;

        for (var i = 0 ; i < 8 ; i++){
            elevatorStopState[i] = [0,0,0];
            elevatorState[i] = 0;
            elevatorProgress[i] = 0;
        }

        /* functions */
        var printDebugInfo = function (msg) {
            if (debugPrint) {
                console.log(msg);
            }
        }
        var printDebugInfoWithId = function (id, msg) {
            printDebugInfo("eid:" + id + " " + msg);
        }

        var isStopTargetFloor = function (destinationFloor, upping) {
            if (upping){
                return stopFloorUpping.includes(destinationFloor);
            }else{
                return stopFloorDowning.includes(destinationFloor);
            }
        };
        var voidCapacity = function(elevator){
            var marginRatio = 1.05;// if 4 of 4 peple ride , capacity ratio is about 0.48..
            //there are men and women and children.
            return elevator.maxPassengerCount() * ( 1.0- elevator.loadFactor()*marginRatio);
        };

        var removeValueFromArray = function (array, value){
            var id = array.indexOf(value);
            if (id != -1){
                array.splice(id,1);
            }
        };
        var deleteDestinationFloor = function (destinationFloor, eid) {
            var e = elevators[eid];
            printDebugInfoWithId(eid, "del destination : "+ destinationFloor
                                 +" from up:"+ stopFloorUpping+" from down:"+ stopFloorDowning);
            removeValueFromArray(e.destinationQueue,destinationFloor);

            if (e.goingUpIndicator()){
                removeValueFromArray(stopFloorUpping,destinationFloor);
                //printDebugInfoWithId(eid,"del destination : "+ destinationFloor +" from up:"+ stopFloorUpping);
            }
            if (e.goingDownIndicator()){
                removeValueFromArray(stopFloorDowning,destinationFloor);
                //printDebugInfoWithId(eid, "del destination : "+ destinationFloor +" from down:"+ stopFloorDowning);
            }
        };
        var isUpperingByElevatorFloor = function(elevator){
            return stopFloorsFlow.length/2>elevator.currentFloor() || stopFloorsFlow.length*17/20<elevator.currentFloor();
        };
        var isDowningByElevatorFloor = function(elevator){
            return stopFloorsFlow.length*3/20<=elevator.currentFloor();
        };
        var hasDestination = function(elevator){
            return (elevator.destinationQueue.length >0);
        };

        //When elevator is idle , this is called.
        var goNextFloor = function(elevator,eid,stopArray){
            var s, f, len;
            len = stopArray.length;
            do {
                elevatorProgress[eid]++;
                s = elevatorProgress[eid];
                if (s >= len) {
                    s = 0;
                    elevatorProgress[eid] = 0;
                }
                if (s > maxFloor && s < len-maxFloor){
                    s = len-maxFloor +1;
                    elevatorProgress[eid] = s;
                }
                f = stopArray[s];
            }while(f > maxFloor && f < 0);
            elevator.goToFloor(f);
            elevatorState[eid] = 2;//aimlessly
            printDebugInfoWithId(eid,"nextto move : step:" + s + "("+elevatorProgress[eid]
                                 +") floor:" + f + "(<max:"+ maxFloor + ") queue:"+elevator.destinationQueue);
        };

        //Elevators move in circular motion
        //See also stopFloorsFlow[]
        var setNextDemandFloor = function(elevator,stopArray,eid,loop) {
            var s  = 0;
            var onGoingUpFloor = [];
            var onGoingDownFloor = [];
            printDebugInfoWithId(eid, "set next floor  progress:" + elevatorProgress[eid] + " up:"
                                 + stopFloorUpping + " down:" + stopFloorDowning
                                 + " press:" + elevator.getPressedFloors()+" queue:"+ elevator.destinationQueue
                                 + " onGoing:" + onGoingUpFloor + "," + onGoingDownFloor + " load : " +
                                 elevator.loadFactor() + " (" + voidCapacity(elevator) + ")"
                                 + " upper?:" + (stopArray.length / 2 > elevatorProgress[eid]) + " stop?:" +
                                 isStopTargetFloor(1, stopArray.length / 2 > elevatorProgress[eid])
                                );

            //for through floor.
            // make skip floor list (onGoingUpFloor/onGoingDownFloor)
            elevators.forEach((ev)=>{
                if (ev != elevator){
                    //roughly
                    if (ev.destinationQueue.length > 0){
                        if (isUpperingByElevatorFloor(ev)){
                            onGoingUpFloor.push(ev.destinationQueue[0]);
                        }
                        if (isDowningByElevatorFloor(ev)){
                            onGoingDownFloor.push(ev.destinationQueue[0]);
                        }
                    }
                    if (isUpperingByElevatorFloor(ev) && ev.getPressedFloors().length < 2 && ev.loadFactor() < 0.18){
                        Array.prototype.push.apply(onGoingUpFloor, ev.getPressedFloors());
                    }
                    if (isDowningByElevatorFloor(ev) && ev.getPressedFloors().length < 2 && ev.loadFactor() < 0.18){
                        Array.prototype.push.apply(onGoingDownFloor, ev.getPressedFloors());
                    }
                }
            });
            //call more often at floor 0.
            if (onGoingUpFloor.includes(0) && Math.random() < 0.55){
                removeValueFromArray(onGoingUpFloor,0);
            }

            // find stop target which are  a or b type floor and skip some type b floor
            // type a: floor pushed by people in elevator
            // type b: floor pushed by people on each floor (classified it by up/dir movement)
            //          skip case:  if elevator has no space
            //                      , floor toward which another elevator is running(see also above section)
            stopArray.forEach((targetFloor, fi) => {
                if (s == 0
                    && elevatorProgress[eid] < fi
                    && (/*type a*/elevator.getPressedFloors().includes(targetFloor)
                        || /*type b*/ (voidCapacity(elevator) >= 1.0 && isStopTargetFloor(targetFloor, stopArray.length / 2 > fi)
                                       && ((isUpperingByElevatorFloor(elevator) && !onGoingUpFloor.includes(targetFloor))
                                           || (isDowningByElevatorFloor(elevator) && !onGoingDownFloor.includes(targetFloor)))
                                      ))
                   ) {
                    elevator.destinationQueue = [];
                    //elevator.checkDestinationQueue();
                    elevator.goToFloor(targetFloor);

                    //Not prevent another direction target(because of not effective)
                    //upper = isStopTargetFloor(targetFloor,stopArray.length/2>fi);
                    /*if (stopFloorsFlow.length/2>fi && targetFloor > elevator.currentFloor()){
                        elevator.goingDownIndicator(false);
                    }else if(stopFloorsFlow.length*2/4<fi && targetFloor < elevator.currentFloor()){
                        elevator.goingUpIndicator(false);
                    }
                    if (targetFloor > elevator.currentFloor() || targetFloor == 0
                        || Math.max(elevator.getPressedFloors()) > elevator.currentFloor() ){
                        elevator.goingUpIndicator(true);
                    }
                    if (targetFloor < elevator.currentFloor() || targetFloor == maxFloor
                        || Math.min(elevator.getPressedFloors()) < elevator.currentFloor() ){
                        elevator.goingDownIndicator(true);
                    }*/
                    printDebugInfoWithId(eid, "set next: go(add queue) floor:"+ targetFloor +"(queue:"+elevator.destinationQueue +")"
                                         + "  progress:" + elevatorProgress[eid] + "<" + fi);
                    s++;
                    //for circular motion,  preserve progress index
                    //   and backup before value for recalc situation(each passing floor, push floor btn, and push button in elevator)
                    elevatorStopState[eid][0] = elevatorProgress[eid];//keep for recalc destination
                    elevatorStopState[eid][1] = elevator.currentFloor();
                    elevatorProgress[eid] = fi;
                    elevatorState[eid] = 1;//0:stop,  1:moving
                    printDebugInfoWithId(eid, "set next: go(add queue) floor-2: progress:"+elevatorProgress[eid]
                                         +"(before:"+elevatorStopState[eid][0] + ") floor:" + elevator.currentFloor()
                                         + "(set before:" + elevatorStopState[eid][1]
                                         + ") state:" + elevatorState[eid]);

                    //only nearest one target is set.
                    return;
                }else{
                    /*if (stopFloorUpping.includes(targetFloor) || stopFloorDowning.includes(targetFloor)){
                        printDebugInfo("skip :" + targetFloor +"("+fi+")" + " s:" + s + "  over:" + (elevatorProgress[eid] < fi)
                                    + " inner press:" +elevator.getPressedFloors()
                                    + " up:" + stopFloorUpping + " down:"+ stopFloorDowning+ " onGoing:"+onGoingFloor
                                    + " load:" + elevator.loadFactor()
                                    +" upper:" + (stopArray.length/2>fi) + "  stopFloor:" + isStopTargetFloor(targetFloor,stopArray.length/2>fi));
                    }*/
                }
            });

            //connect last index(No.1) with first index(No.0) only one time(see also  s, loop).
            // i.e.(see also stopFloorsFlow[])
            // floor 0 : id: 0 , value: 0
            // floor 1 : id  1 , value: 1
            // floor 19: id  19 , value 19
            // floor 20: id  20 , value 20
            // floor 19: id  21 , value 19
            // floor 1:  id  39(max), value 1
            if (s == 0){
                if (loop > 0) {
                    //connect last flow with first flow
                    printDebugInfoWithId(eid, "set next: no candidate(recheck again):  set progress -1(floor 0): queue:" + elevator.destinationQueue);
                    //tempolary change value just in case.
                    var tp = elevatorProgress[eid];
                    elevatorProgress[eid] = -1;
                    //I expect that absolutely elevator can find floor if there is demand.
                    setNextDemandFloor(elevator,stopArray,eid,0);
                    if (!hasDestination(elevator)){
                        elevatorProgress[eid] = tp;
                        printDebugInfoWithId(eid,"back and set.  progress:"+elevatorProgress[eid]);//temp
                    }
                    if (elevatorProgress[eid] < 0 || elevatorProgress[eid] >= stopFloorsFlow.length){
                        printDebugInfoWithId(eid,"error: progress:"+elevatorProgress[eid]);//temp
                        elevatorProgress[eid] = 0;
                        elevator.goToFloor(0);
                    }
                } else {
                    //miss (no waiting people with no elevator running)
                    printDebugInfoWithId(eid, "set next: no candidate(no recheck):  queue" + elevator.destinationQueue);
                    elevatorState[eid] = 0;//stop state
                }
            }
        };

        //find nearest almost vacant elevator from targetFloor and direct it to there.
        var recheckNextStopByNotify = function(targetFloor){
            var floorGap = 100;//maxFloor *2/5;
            var eid = -1;
            var tp;
            elevators.forEach((e,id)=>{
                tp = e.currentFloor()-targetFloor;
                printDebugInfo("recheck : elevator load id:" + id + " load:"+e.loadFactor() + "("+hasDestination(e)+")  state:"+elevatorState[id]
                            + " targetFloor:" + targetFloor + " f tp:"+tp + " queue:"+e.destinationQueue);
                if ( e.loadFactor() == 0 
                    && ((hasDestination(e) && Math.abs(e.destinationQueue[0]-e.currentFloor())> Math.abs(tp))
                        || !hasDestination(e) /*|| elevatorState[id] != 1/*aimlessly*/)){
                    printDebugInfo("id:"+ id);
                    if (Math.abs(tp) < floorGap
                        &&((tp >= 0 && e.goingDownIndicator()
                            && elevatorProgress[id]>stopFloorsFlow.length*2/5)
                           ||(tp <= 0 && e.goingUpIndicator()
                              && (elevatorProgress[id] < stopFloorsFlow.length / 2
                                  || elevatorProgress[id] > stopFloorsFlow.length * 4 / 5)))) {
                        floorGap = Math.abs(tp);
                        eid = id;
                    }
                }
            });
            if (eid == -1){
                printDebugInfoWithId(eid, "recheck : skip :" + floorGap + " tp:"+tp);
            } else {
                //set floor
                //setNextFloor(elevators[eid],stopFloorsFlow,eid, 1);
                elevators[eid].destinationQueue = [];
                //elevators[eid].checkDestinationQueue();
                elevators[eid].goToFloor(targetFloor);

                //set id
                elevatorStopState[eid][0] = elevatorProgress[eid];
                elevatorProgress[eid] = targetFloor;
                if (tp > 0){
                    elevatorProgress[eid] = stopFloorsFlow.length - targetFloor;
                    if (targetFloor == 0){
                        elevatorProgress[eid] = 0;
                    }
                }

                elevatorState[eid] = 1;//0:stop,  1:moving
                /*if (elevatorProgress[eid] >= stopFloorsFlow.length -1){
                    elevatorProgress[eid] = -1;
                }*/

                if (elevatorState[eid] != 0){
                    printDebugInfoWithId(eid, "push floor button:"+targetFloor+" let people waiting. nearest elevator is working..");
                    return;
                }
            }
        };

        //floor each events
        floors.forEach( (f)=>{

            //get max floor
            if (f.floorNum() > maxFloor){
                maxFloor = f.floorNum();
            }

            //append share memory (waiting floor with up/down direction)
            //This event is occure only when button is off.
            //  so, It means only first time and We cannot know how many people there.
            f.on("up_button_pressed", function() {
                printDebugInfo("push floor button:" + f.floorNum() + " up" );
                if (!stopFloorUpping.includes(f.floorNum())){
                    stopFloorUpping.push(f.floorNum());
                    printDebugInfo("floor push up:"+ f.floorNum() + " =>" + stopFloorUpping);
                }
                //if elevator is free let them go there
                recheckNextStopByNotify(f.floorNum());
            });
            f.on("down_button_pressed", function() {
                printDebugInfo("push floor button:" + f.floorNum() + " down" );
                if (!stopFloorDowning.includes(f.floorNum())){
                    stopFloorDowning.push(f.floorNum());
                    printDebugInfo("floor push down:"+ f.floorNum() + " =>" + stopFloorDowning);
                }
                recheckNextStopByNotify(f.floorNum());
            });
        });

        //elevator each events
        elevators.forEach((e, id) => {
            //init
            elevatorProgress[id] = -1;
            elevatorState[id] = 0;
            elevatorStopState[id][0] = elevatorProgress[id];
            elevatorStopState[id][1] = e.currentFloor();

            // Whenever the elevator is idle (has no more queued destinations) ...
            // If arrive at goal , seek next stop. (This algorithm set only one goal each time)
            //note.  sometimes called  such case when queue is cleared.
            e.on("idle",function(){
                printDebugInfo(id, "idle.   progress:"+elevatorProgress[id]+ "(before:"+elevatorStopState[id][0]+")"
                               + " floor(elevator):"+ e.currentFloor() +", before progress:"+ stopFloorsFlow[elevatorProgress[id]]
                               + " queue:" + e.destinationQueue
                               + " up:" + stopFloorUpping + " down:" + stopFloorDowning);
                //before arrive at destination
                if (e.currentFloor() != stopFloorsFlow[elevatorProgress[id]]) {
                    //reserve progress index position
                    elevatorProgress[id] = elevatorStopState[id][0];
                }
                setNextDemandFloor(e, stopFloorsFlow, id, 1);

                //no another target
                //continuously launch stop event
                // not enough affect...
                while (!hasDestination(e)) {
                    //go next index floor.
                    goNextFloor(e,id,stopFloorsFlow);
                }
            });

            //when stop at floor
            // not after pushin button in elevator (so next event is need)
            // There is no notify event which tell count of waiting people
            //  or elevator is full(only weight capacity ratio)
            e.on("stopped_at_floor",function(floorNum){
                printDebugInfoWithId(id, "stop.   progress:"+elevatorProgress[id]+ "(before:"+elevatorStopState[id][0]+")"
                                     + " floor:"+ e.currentFloor() +"(elevator), "+floorNum+"(event),"+ stopFloorsFlow[elevatorProgress[id]]+"(by progress"
                                     + " up:" + stopFloorUpping + " down:"+stopFloorDowning);
                if (floorNum == e.currentFloor()){
                    elevatorState[id] = 0;
                }
                //Update share memory
                deleteDestinationFloor(e.currentFloor(),id,stopFloorsFlow.length/2>elevatorProgress[id] );

                //like idle case
                if (floorNum == stopFloorsFlow[elevatorProgress[id]] || elevatorProgress[id] == -1 ){
                    setNextDemandFloor(e,stopFloorsFlow,id, 1);
                } else {
                    //stopping interactively case in the middle of the way when passing.
                    // new stop floor is checked by passing elevator event when passing.
                    e.goToFloor(stopFloorsFlow[elevatorProgress[id]]);
                }
            });
            //recalc only one goal floor.
            //like idle event
            //Note: after starting  this event is also called (not before moving).
            //    : only first person's event is notified(not pushed floor btn).
            e.on("floor_button_pressed", function(floorNum) {
                printDebugInfoWithId(id, "push(in).  floor: (btn:"+ floorNum + ", elevator:"+ e.currentFloor() +" bef:" +elevatorStopState[id][1]
                                     + ") load:"+ e.loadFactor()
                                     + " progress:"+elevatorProgress[id] +"<=floor:"+stopFloorsFlow[elevatorProgress[id]]
                                     +"(bef:"+ elevatorStopState[id][0] +"<=floor:"+stopFloorsFlow[elevatorStopState[id][0]]+")");
                if (stopFloorsFlow[elevatorProgress[id]] == e.currentFloor()){
                }else{
                    var pg = stopFloorsFlow[elevatorProgress[id]]-stopFloorsFlow[elevatorStopState[id][0]];
                    //place back recalc progress start point.
                    elevatorProgress[id] = Math.round(e.currentFloor());
                    if (pg > 0){
                    }else{
                        elevatorProgress[id] = stopFloorsFlow.length - elevatorProgress[id];
                        if (elevatorProgress[id] == 0){
                            elevatorProgress[id] = 0;
                        }
                    }
                }
                setNextDemandFloor(e, stopFloorsFlow, id, 1);
            });
            e.on("passing_floor", function(floorNum, direction) {
                //because elevator start moving before riding people push button in elevator.
                /*printDebugInfoWithId(id, "passing: floor:"+ floorNum + " dir:"+direction
                            + "  elevator:"+e.currentFloor() + " progress:"+ elevatorProgress[id]
                            + " queue:"+e.destinationQueue + " buttons:"+e.getPressedFloors()
                            + " up:" + stopFloorUpping + " down:"+stopFloorDowning);*/
                var p = elevatorStopState[id][0];
                //stop immidiately
                if (e.getPressedFloors().includes(floorNum) && Math.round(e.currentFloor() - floorNum) == 0) {
                    //judge by button in the elevator
                    printDebugInfoWithId(id, "stop(in).  floor:"+ floorNum + " dir:"+direction+ " queue:" + e.destinationQueue);
                    e.destinationQueue = [];
                    //don't use stop(). elevator stop systematically.
                    //e.checkDestinationQueue();
                    e.goToFloor(floorNum);
                    //recalc next goal lately
                }else{
                    //judge by floor up/down button.
                    if (hasDestination(e) > 1.0
                        && ((direction == "up" && stopFloorUpping.includes(floorNum))
                            || (direction == "down" && stopFloorDowning.includes(floorNum)))){
                        printDebugInfoWithId(id, "stop(out).  floor:"+ floorNum + " dir:"+direction);
                        e.destinationQueue = [];
                        e.goToFloor(floorNum);
                        elevatorProgress[id] = p;
                    }
                }
            });
        });
    },
        update: function(dt, elevators, floors) {
            // We normally don't need to do anything here
        }
}

おまけ

エレベーター解消方法の他の例を知りたい方はこちらをどうぞ

「エレベーター渋滞」を改善したリクルートの超アナログな方法(ダイヤモンド・オンライン) - Yahoo!ニュース
https://headlines.yahoo.co.jp/article?a=20190327-00197985-diamond-bus_all


渋滞学を知りたいなら、待ち行列モデル以下をどうぞ

--- --- ===
Delicious にシェア
Digg にシェア
reddit にシェア
LinkedIn にシェア
LINEで送る
email this
Pocket

270 views.



コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です