diff --git a/extern/util/CanvasInput.js b/extern/util/CanvasInput.js index 813bba72cf..8ce4ccbf85 100755 --- a/extern/util/CanvasInput.js +++ b/extern/util/CanvasInput.js @@ -9,7 +9,7 @@ * * MIT License */ -(function() { +(function () { // create a buffer that stores all inputs so that tabbing // between them is made possible. var inputs = []; // initialize the Canvas Input @@ -46,15 +46,15 @@ self._placeHolder = o.placeHolder || ''; self._value = o.value || self._placeHolder; - self._onsubmit = o.onsubmit || function() {}; + self._onsubmit = o.onsubmit || function () {}; - self._onkeydown = o.onkeydown || function() {}; + self._onkeydown = o.onkeydown || function () {}; - self._onkeyup = o.onkeyup || function() {}; + self._onkeyup = o.onkeyup || function () {}; - self._onfocus = o.onfocus || function() {}; + self._onfocus = o.onfocus || function () {}; - self._onblur = o.onblur || function() {}; + self._onblur = o.onblur || function () {}; self._cursor = false; self._cursorPos = 0; @@ -95,7 +95,7 @@ if (self._canvas) { self._canvas.addEventListener( 'mousemove', - function(e) { + function (e) { e = e || window.event; self.mousemove(e, self); }, @@ -104,7 +104,7 @@ self._canvas.addEventListener( 'mousedown', - function(e) { + function (e) { e = e || window.event; self.mousedown(e, self); }, @@ -113,7 +113,7 @@ self._canvas.addEventListener( 'touchstart', - function(e) { + function (e) { e = e || window.event; self.mousedown(e, self); }, @@ -122,7 +122,7 @@ self._canvas.addEventListener( 'mouseup', - function(e) { + function (e) { e = e || window.event; self.mouseup(e, self); }, @@ -132,7 +132,7 @@ window.addEventListener( 'mouseup', - function(e) { + function (e) { //e = e || window.event; //if (self._hasFocus && !self._mouseDown) { //self.blur(); @@ -160,7 +160,7 @@ document.body.appendChild(self._hiddenInput); self._hiddenInput.value = self._value; // setup the keydown listener - self._hiddenInput.addEventListener('keydown', function(e) { + self._hiddenInput.addEventListener('keydown', function (e) { e = e || window.event; if (self._hasFocus) { @@ -168,7 +168,7 @@ } }); // setup the keyup listener - self._hiddenInput.addEventListener('keyup', function(e) { + self._hiddenInput.addEventListener('keyup', function (e) { e = e || window.event; // update the canvas input state information from the hidden input self._value = self._hiddenInput.value; @@ -726,8 +726,8 @@ clearInterval(self._cursorInterval); } - requestAnimationFrame(function() { - self._cursorInterval = setInterval(function() { + requestAnimationFrame(function () { + self._cursorInterval = setInterval(function () { self._cursor = !self._cursor; self.render(); }, 500); // check if this is Chrome for Android (there is a bug with returning incorrect character key codes) @@ -741,6 +741,8 @@ var isMobile = typeof window.orientation !== 'undefined'; var hasHiddenFocus = false; + var inputParent = + document.fullscreenElement || document.webkitFullscreenElement || document.body; if ( isMobile && !isChromeMobile && @@ -763,11 +765,11 @@ input.style.height = 0; const form = document.createElement('form'); form.appendChild(input); - document.body.appendChild(form); + inputParent.appendChild(form); input.focus(); input.addEventListener( 'blur', - function() { + function () { if (!hasHiddenFocus) { self.blur(self); } @@ -787,6 +789,9 @@ var hasSelection = self._selection[0] > 0 || self._selection[1] > 0; hasHiddenFocus = true; + if (self._hiddenInput.parentNode !== inputParent) { + inputParent.appendChild(self._hiddenInput); + } self._hiddenInput.focus(); hasHiddenFocus = false; self._hiddenInput.selectionStart = hasSelection ? self._selection[0] : self._cursorPos; @@ -869,7 +874,7 @@ if (inputs.length > 1) { var next = inputs[self._inputsIndex + 1] ? self._inputsIndex + 1 : 0; self.blur(); - setTimeout(function() { + setTimeout(function () { inputs[next].focus(); }, 5); } @@ -1088,7 +1093,7 @@ ctx.shadowBlur = 0; } // draw the text box background - self._drawTextBox(function() { + self._drawTextBox(function () { // make sure all shadows are reset ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; @@ -1234,7 +1239,7 @@ var img = new Image(); img.src = self._backgroundImage; - img.onload = function() { + img.onload = function () { ctx.drawImage( img, 0, diff --git a/images/hardware/codingboxv2.png b/images/hardware/codingboxv2.png new file mode 100644 index 0000000000..b7cae15fad Binary files /dev/null and b/images/hardware/codingboxv2.png differ diff --git a/package.json b/package.json index ca3fc4de56..edc65650c1 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "jest": "^24.9.0", "khaiii": "^0.0.2", "libsvm-js": "^0.2.1", - "lodash": "^4.17.15", + "lodash": "^4.17.21", "mathjs": "^7.1.0", "ml-cart": "^2.1.1", "pixi.js": "5.3.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7de17ddfcb..a8bbc770f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ importers: specifier: ^0.2.1 version: 0.2.1 lodash: - specifier: ^4.17.15 + specifier: ^4.17.21 version: 4.17.23 mathjs: specifier: ^7.1.0 diff --git a/src/class/pixi/etc/PIXICanvasInput.js b/src/class/pixi/etc/PIXICanvasInput.js index 3565769eee..2f9ce66119 100644 --- a/src/class/pixi/etc/PIXICanvasInput.js +++ b/src/class/pixi/etc/PIXICanvasInput.js @@ -8,14 +8,14 @@ * MIT License */ import * as PIXI from 'pixi.js'; -(function() { +(function () { // create a buffer that stores all inputs so that tabbing // between them is made possible. const inputs = []; // initialize the Canvas Input // eslint-disable-next-line no-multi-assign - const CanvasInput = (window.PIXICanvasInput = function(o) { + const CanvasInput = (window.PIXICanvasInput = function (o) { const self = this; o = o ? o : {}; @@ -48,11 +48,11 @@ import * as PIXI from 'pixi.js'; self._selectionColor = o.selectionColor || 'rgba(179, 212, 253, 0.8)'; self._placeHolder = o.placeHolder || ''; self._value = `${o.value || self._placeHolder}`; - self._onsubmit = o.onsubmit || function() {}; - self._onkeydown = o.onkeydown || function() {}; - self._onkeyup = o.onkeyup || function() {}; - self._onfocus = o.onfocus || function() {}; - self._onblur = o.onblur || function() {}; + self._onsubmit = o.onsubmit || function () {}; + self._onkeydown = o.onkeydown || function () {}; + self._onkeyup = o.onkeyup || function () {}; + self._onfocus = o.onfocus || function () {}; + self._onblur = o.onblur || function () {}; self._cursor = false; self._cursorPos = 0; self._hasFocus = false; @@ -772,6 +772,8 @@ import * as PIXI from 'pixi.js'; // add support for mobile const isMobile = typeof window.orientation !== 'undefined'; var hasHiddenFocus = false; + const inputParent = + document.fullscreenElement || document.webkitFullscreenElement || document.body; if ( isMobile && !isChromeMobile && @@ -782,17 +784,17 @@ import * as PIXI from 'pixi.js'; input.type = 'text'; input.style.opacity = 0; input.style.position = 'absolute'; - input.style.left = `${self._x + - self._extraX + - (self._canvas ? self._canvas.offsetLeft : 0)}px`; - input.style.top = `${self._y + - self._extraY + - (self._canvas ? self._canvas.offsetTop : 0)}px`; + input.style.left = `${ + self._x + self._extraX + (self._canvas ? self._canvas.offsetLeft : 0) + }px`; + input.style.top = `${ + self._y + self._extraY + (self._canvas ? self._canvas.offsetTop : 0) + }px`; input.style.width = self._width; input.style.height = 0; const form = document.createElement('form'); form.appendChild(input); - document.body.appendChild(form); + inputParent.appendChild(form); input.focus(); input.addEventListener( 'blur', @@ -817,6 +819,9 @@ import * as PIXI from 'pixi.js'; // move the real focus to the hidden input const hasSelection = self._selection[0] > 0 || self._selection[1] > 0; hasHiddenFocus = true; + if (self._hiddenInput.parentNode !== inputParent) { + inputParent.appendChild(self._hiddenInput); + } self._hiddenInput.focus(); hasHiddenFocus = false; self._hiddenInput.selectionStart = hasSelection ? self._selection[0] : self._cursorPos; @@ -1284,7 +1289,7 @@ import * as PIXI from 'pixi.js'; } else { const img = new Image(); img.src = self._backgroundImage; - img.onload = function() { + img.onload = function () { ctx.drawImage( img, 0, diff --git a/src/class/playground.js b/src/class/playground.js index 525d375a5a..ed3f147943 100644 --- a/src/class/playground.js +++ b/src/class/playground.js @@ -704,9 +704,11 @@ Entry.Playground = class Playground { updatePictureView() { if (this.pictureSortableListWidget) { - this.pictureSortableListWidget.setData({ items: [] }); - this.pictureSortableListWidget.setData({ - items: this._getSortablePictureList(), + Entry.Utils.runWithScrollPreserved(this.pictureListView_, () => { + this.pictureSortableListWidget.setData({ items: [] }); + this.pictureSortableListWidget.setData({ + items: this._getSortablePictureList(), + }); }); } this.reloadPlayground(); @@ -1102,8 +1104,10 @@ Entry.Playground = class Playground { updateSoundsView() { if (this.soundSortableListWidget) { - this.soundSortableListWidget.setData({ - items: this._getSortableSoundList(), + Entry.Utils.runWithScrollPreserved(this.soundListView_, () => { + this.soundSortableListWidget.setData({ + items: this._getSortableSoundList(), + }); }); } diff --git a/src/class/scene.js b/src/class/scene.js index e2ca9da4c9..b58c91f27a 100644 --- a/src/class/scene.js +++ b/src/class/scene.js @@ -172,7 +172,11 @@ Entry.Scene = class { updateSceneView() { const items = this._getSortableSceneList(); if (this.sceneSortableListWidget) { - setTimeout(() => this.sceneSortableListWidget.setData({ items }), 0); + setTimeout(() => { + Entry.Utils.runWithScrollPreserved(this.listView_, () => { + this.sceneSortableListWidget.setData({ items }); + }); + }, 0); } } diff --git a/src/class/stage.js b/src/class/stage.js index 72141bdc38..d00d213332 100644 --- a/src/class/stage.js +++ b/src/class/stage.js @@ -580,6 +580,8 @@ Entry.Stage = class Stage { this.inputField.show(); this.canvas.addChild(this.inputSubmitButton); + this._bindInputFieldFullScreenChange(); + Entry.requestUpdateTwice = true; function _createInputField() { @@ -664,6 +666,32 @@ Entry.Stage = class Stage { Entry.requestUpdate = true; } + _bindInputFieldFullScreenChange() { + if (this._inputFieldFullScreenChangeBound) { + return; + } + this._inputFieldFullScreenChangeBound = true; + + const relocate = () => { + const inputField = this.inputField; + const hiddenInput = inputField && inputField._hiddenInput; + if (!hiddenInput) { + return; + } + const inputParent = + document.fullscreenElement || document.webkitFullscreenElement || document.body; + if (hiddenInput.parentNode !== inputParent) { + inputParent.appendChild(hiddenInput); + if (inputField._hasFocus) { + hiddenInput.focus(); + } + } + }; + + document.addEventListener('fullscreenchange', relocate); + document.addEventListener('webkitfullscreenchange', relocate); + } + /** * init object containers */ diff --git a/src/class/variable/variable.js b/src/class/variable/variable.js index 3dd416ca2d..58540695d1 100644 --- a/src/class/variable/variable.js +++ b/src/class/variable/variable.js @@ -9,6 +9,15 @@ import BinPacking from '../../util/binPacking'; const VariableBP = new BinPacking(460, 250); const bpReplace = _throttle(VariableBP.replace.bind(VariableBP)); +/* + * 변수 표시창 좌표 허용 범위. + * Number.MAX_VALUE 같은 극단값이 좌표로 저장된 프로젝트의 경우 스테이지 스케일과 곱해질 때 + * Infinity 로 오버플로되고, easeljs 히트테스트의 setTransform 이 non-finite 인자로 무시되어 + * 마우스 커서가 항상 변수 뷰에 걸리는 문제가 있었음. + * 표시창을 화면 밖으로 숨기는 용도는 이 범위로 충분하다. + */ +const COORDINATE_LIMIT = 10000; + /** * 기본 변수블록 객체 * @param {Object} variable variableMetadata @@ -65,9 +74,9 @@ class Variable { this.visible_ = variable.visible || typeof variable.visible === 'boolean' ? variable.visible : true; /** @type {number} */ - this.x_ = variable.x ? variable.x : null; + this.x_ = variable.x ? Variable.sanitizeCoordinate(variable.x) : null; /** @type {number} */ - this.y_ = variable.y ? variable.y : null; + this.y_ = variable.y ? Variable.sanitizeCoordinate(variable.y) : null; const fontFamily = EntryStatic.fontFamily || "NanumGothic, 'Nanum Gothic'"; this.BORDER = 6; this.FONT = `10pt ${fontFamily}`; @@ -376,13 +385,27 @@ class Variable { this.updateView(); } + /** + * 좌표값을 유한한 허용 범위로 보정한다. + * 숫자가 아니거나 NaN 이면 0, 범위를 벗어나면 ±COORDINATE_LIMIT 로 clamp. + * @param {number} value + * @return {number} + */ + static sanitizeCoordinate(value) { + const num = Number(value); + if (!Number.isFinite(num)) { + return 0; + } + return Entry.adjustValueWithMaxMin(num, -COORDINATE_LIMIT, COORDINATE_LIMIT); + } + /** * X coordinate setter * @param {number} x */ setX(x) { /** @type {number} */ - this.x_ = x; + this.x_ = Variable.sanitizeCoordinate(x); this.updateView(); } @@ -400,7 +423,7 @@ class Variable { */ setY(y) { /** @type {number} */ - this.y_ = y; + this.y_ = Variable.sanitizeCoordinate(y); this.updateView(); } diff --git a/src/playground/blocks/hardware/block_KKMOO.js b/src/playground/blocks/hardware/block_KKMOO.js index 972b5a4af2..6d16651f71 100644 --- a/src/playground/blocks/hardware/block_KKMOO.js +++ b/src/playground/blocks/hardware/block_KKMOO.js @@ -653,15 +653,23 @@ Entry.kkmoo.getBlocks = function () { func: function (sprite, script) { const motnum = script.getField('MOTNUM', script); const angle = script.getValue('ANGLE', script); + const index = Number(motnum); + if ( + !Number.isInteger(index) || + index < 0 || + index >= Entry.kkmoo.motData.length + ) { + return script.callReturn(); + } Entry.hw.update(); if (script.isStart != true) { script.isStart = true; if (angle >= -90 && angle <= 90) { - Entry.kkmoo.motData[motnum].angle = angle; + Entry.kkmoo.motData[index].angle = angle; } else if (angle > 90) { - Entry.kkmoo.motData[motnum].angle = 90; + Entry.kkmoo.motData[index].angle = 90; } else { - Entry.kkmoo.motData[motnum].angle = -90; + Entry.kkmoo.motData[index].angle = -90; } return script; } else { @@ -854,11 +862,19 @@ Entry.kkmoo.getBlocks = function () { isNotFor: ['kkmoo'], func: function (sprite, script) { const motnum = script.getField('FRAME', script); + const index = Number(motnum); + if ( + !Number.isInteger(index) || + index < 0 || + index >= Entry.kkmoo.motionFrame.length + ) { + return script.callReturn(); + } Entry.hw.update(); if (script.isStart != true) { script.isStart = true; var data = Entry.kkmoo.copyObj(Entry.kkmoo.motData); - Entry.kkmoo.motionFrame[motnum].data = data; + Entry.kkmoo.motionFrame[index].data = data; return script; } else { delete script.isStart; @@ -974,10 +990,18 @@ Entry.kkmoo.getBlocks = function () { func: function (sprite, script) { const motnum = script.getField('FRAME', script); const time = script.getValue('TIME', script); + const index = Number(motnum); + if ( + !Number.isInteger(index) || + index < 0 || + index >= Entry.kkmoo.motionFrame.length + ) { + return script.callReturn(); + } Entry.hw.update(); if (script.isStart != true) { script.isStart = true; - Entry.kkmoo.motionFrame[motnum].time = time; + Entry.kkmoo.motionFrame[index].time = time; return script; } else { delete script.isStart; diff --git a/src/playground/blocks/hardware/block_altino_neo.js b/src/playground/blocks/hardware/block_altino_neo.js index b5c5c34117..690ad1f497 100644 --- a/src/playground/blocks/hardware/block_altino_neo.js +++ b/src/playground/blocks/hardware/block_altino_neo.js @@ -458,21 +458,21 @@ Entry.AltinoNeo.getBlocks = function() { if (direction == 'Center') { sq.steering = 0; } else if (direction == 'Left5') { - sq.steering = 0xe0; + sq.steering = -31; } else if (direction == 'Left10') { - sq.steering = 0xc0; + sq.steering = -63; } else if (direction == 'Left15') { - sq.steering = 0xa1; + sq.steering = -95; } else if (direction == 'Left20') { - sq.steering = 0x81; + sq.steering = -127; } else if (direction == 'Right5') { sq.steering = 31; } else if (direction == 'Right10') { sq.steering = 63; } else if (direction == 'Right15') { - sq.steering = 94; + sq.steering = 95; } else if (direction == 'Right20') { - sq.steering = 126; + sq.steering = 127; } return script.callReturn(); }, diff --git a/src/playground/blocks/hardware/block_armz.js b/src/playground/blocks/hardware/block_armz.js index b8637d93b5..568e5d6a63 100644 --- a/src/playground/blocks/hardware/block_armz.js +++ b/src/playground/blocks/hardware/block_armz.js @@ -1,9 +1,5 @@ 'use strict'; -const _set = require('lodash/set'); -const _get = require('lodash/get'); -const _merge = require('lodash/merge'); - Entry.Armz = new class Armz { constructor() { // this.id = 'FF.FF'; @@ -104,9 +100,7 @@ Entry.Armz = new class Armz { postSendQueue({ script, data }, scope) { const blockId = this.getHashKey(); - _merge(Entry.hw.sendQueue, { - [blockId]: data, - }); + Entry.hw.sendQueue[blockId] = data; } postCallReturn(args) { diff --git a/src/playground/blocks/hardware/block_asomebot.js b/src/playground/blocks/hardware/block_asomebot.js index 88ebfa193c..4e4809e867 100644 --- a/src/playground/blocks/hardware/block_asomebot.js +++ b/src/playground/blocks/hardware/block_asomebot.js @@ -58,9 +58,7 @@ Entry.AsomeBot = { scope.timeFlag = 1; this.nowBlockId = blockId; this.blockIds[blockId] = false; - _merge(Entry.hw.sendQueue, { - [blockId]: data, - }); + Entry.hw.sendQueue[blockId] = data; Entry.hw.update(); setTimeout(() => { scope.timeFlag = 0; diff --git a/src/playground/blocks/hardware/block_asomekit.js b/src/playground/blocks/hardware/block_asomekit.js index c3ff687491..49d060dbed 100644 --- a/src/playground/blocks/hardware/block_asomekit.js +++ b/src/playground/blocks/hardware/block_asomekit.js @@ -58,9 +58,7 @@ Entry.AsomeKit = { scope.timeFlag = 1; this.nowBlockId = blockId; this.blockIds[blockId] = false; - _merge(Entry.hw.sendQueue, { - [blockId]: data, - }); + Entry.hw.sendQueue[blockId] = data; Entry.hw.update(); setTimeout(() => { scope.timeFlag = 0; diff --git a/src/playground/blocks/hardware/block_codingboxv2.js b/src/playground/blocks/hardware/block_codingboxv2.js new file mode 100644 index 0000000000..3417d7ac8b --- /dev/null +++ b/src/playground/blocks/hardware/block_codingboxv2.js @@ -0,0 +1,1626 @@ +'use strict'; + +Entry.CodingBoxV2 = new (class CodingBoxV2 { + constructor() { + this.functionKeys = { + SET_LED: 'set-led', + RESET: 'reset', + SERVO: 'servo', + TONE: 'tone', + OLED_PRINT: 'oled-print', + OLED_ICON: 'oled-icon', + OLED_CLEAR: 'oled-clear', + FAN: 'fan', + RAINBOW_LIST: 'rainbow-list', + RAINBOW_ALL: 'rainbow-all', + RAINBOW_ONE: 'rainbow-one', + RAINBOW_CLEAR: 'rainbow-clear', + WIFI_CONNECT: 'wifi-connect', + WIFI_DISCONNECT: 'wifi-disconnect', + WEBPANEL_RESET: 'webpanel-reset', + WEBPANEL_TITLE: 'webpanel-title', + WEBPANEL_READOUT: 'webpanel-readout', + }; + + this.id = ['6F.1']; + this.url = ''; + this.imageName = 'codingboxv2.png'; + this.title = { + en: 'PLAYCODING BOX V2.0', + ko: '플레이코딩 박스 V2.0', + }; + this.name = 'codingboxv2'; + this.communicationType = 'manual'; + + this.commandStatus = {}; + this.commandValue = {}; + + this.blockMenuBlocks = [ + 'codingboxv2_set_led', + 'codingboxv2_is_button_pressed', + + 'codingboxv2_get_light', + 'codingboxv2_get_sound', + 'codingboxv2_get_pir', + 'codingboxv2_get_distance', + 'codingboxv2_get_joystick', + 'codingboxv2_get_temperature', + 'codingboxv2_get_humidity', + 'codingboxv2_get_pressure', + 'codingboxv2_get_altitude', + // 'codingboxv2_get_compass_x', + // 'codingboxv2_get_compass_y', + // 'codingboxv2_get_compass_z', + 'codingboxv2_get_heading', + 'codingboxv2_get_rfid', + + 'codingboxv2_set_servo', + 'codingboxv2_play_note', + + 'codingboxv2_oled_print', + 'codingboxv2_oled_icon', + 'codingboxv2_oled_clear', + + 'codingboxv2_set_fan_power', + 'codingboxv2_fan_off', + + 'codingboxv2_set_rainbow_all', + 'codingboxv2_set_rainbow_one', + 'codingboxv2_set_rainbow_one_rgb', + 'codingboxv2_set_rainbow_list', + 'codingboxv2_clear_rainbow', + + 'codingboxv2_wifi_connect', + 'codingboxv2_wifi_disconnect', + 'codingboxv2_wifi_is_connected', + + 'codingboxv2_webpanel_reset', + 'codingboxv2_webpanel_title', + 'codingboxv2_webpanel_readout', + 'codingboxv2_webpanel_url', + 'codingboxv2_bottom_space', + ]; + + this.ledColorMenu = [ + ['빨강', 'red'], + ['노랑', 'yellow'], + ['초록', 'green'], + ['모두', 'all'], + ]; + + this.ledStateMenu = [ + ['켜기', '1'], + ['끄기', '0'], + ]; + + this.noteMenu = [ + ['도', '262'], + ['레', '294'], + ['미', '330'], + ['파', '349'], + ['솔', '392'], + ['라', '440'], + ['시', '494'], + ['높은 도', '523'], + ]; + + this.oledIconMenu = [ + ['웃는 얼굴', 'smile'], + ['우는 얼굴', 'sad'], + ['큰 하트', 'bigheart'], + ['작은 하트', 'smallheart'], + ['체크', 'check'], + ['X', 'x'], + ['오리', 'duck'], + ['로켓', 'rocket'], + ['음표', 'note'], + ['네모', 'square'], + ['세모', 'triangle'], + ]; + + this.buttonColorMenu = [ + ['빨강', '1'], + ['노랑', '2'], + ['초록', '3'], + ]; + + this.joystickAxisMenu = [ + ['X', 'x'], + ['Y', 'y'], + ]; + + this.webPanelSensorMenu = [ + ['조도 센서', 'light'], + ['사운드 센서', 'sound'], + ['인체 감지 센서', 'pir'], + ['초음파 거리', 'distance'], + ['온도', 'temperature'], + ['습도', 'humidity'], + ['기압', 'pressure'], + ['고도', 'altitude'], + ['나침반 방향', 'heading'], + ['RFID ID', 'rfid'], + ['와이파이 연결 상태', 'wifiConnected'], + ]; + } + + setZero() { + this.requestCommand(this.functionKeys.RESET, 0); + this.commandStatus = {}; + this.commandValue = {}; + } + + requestCommand(type, payload) { + Entry.hw.sendQueue = { + type, + payload, + }; + Entry.hw.update(); + } + + afterReceive(portData) { + if (!portData) { + return; + } + + if (!Entry.engine.isState('run')) { + this.commandStatus = {}; + } + } + + setLanguage() { + return { + ko: { + template: { + codingboxv2_set_led: '%1 LED를 %2', + codingboxv2_get_light: '조도 센서 값', + codingboxv2_get_sound: '사운드 센서 값', + codingboxv2_get_pir: '인체가 감지되었는가?', + codingboxv2_get_distance: '초음파 거리(cm)', + codingboxv2_get_joystick: '방향 버튼 %1 값', + codingboxv2_set_servo: '서보 모터 각도를 %1 도로 정하기', + codingboxv2_play_note: '스피커 %1 음을 %2 박자 연주하기', + codingboxv2_oled_print: 'OLED의 X %1 Y %2 위치에 %3 %4 출력하기', + codingboxv2_oled_icon: 'OLED에 %1 아이콘 표시하기', + codingboxv2_oled_clear: 'OLED 지우기', + codingboxv2_get_temperature: '온도(℃)', + codingboxv2_get_humidity: '습도(%)', + codingboxv2_get_pressure: '기압(hPa)', + codingboxv2_get_altitude: '고도(m)', + codingboxv2_set_fan_power: '모터 팬 세기를 %1 (-100 ~ 100)으로 정하기', + codingboxv2_fan_off: '모터 팬 끄기', + // codingboxv2_get_compass_x: '나침반 X축 값', + // codingboxv2_get_compass_y: '나침반 Y축 값', + // codingboxv2_get_compass_z: '나침반 Z축 값', + codingboxv2_get_heading: '나침반 방향(°)', + codingboxv2_is_button_pressed: '%1 버튼이 눌렸는가?', + codingboxv2_get_rfid: 'RFID ID', + codingboxv2_set_rainbow_all: '레인보우 LED 전체를 %1 색으로 정하기', + codingboxv2_set_rainbow_one: '레인보우 LED %1 번째를 %2 색으로 정하기', + codingboxv2_set_rainbow_one_rgb: '레인보우 LED %1 번째를 R %2 G %3 B %4 색으로 정하기', + codingboxv2_set_rainbow_list: '레인보우 LED 색을 %1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11 %12 으로 정하기', + codingboxv2_clear_rainbow: '레인보우 LED 모두 끄기', + codingboxv2_wifi_connect: '와이파이 이름 %1 비밀번호 %2 로 연결하기', + codingboxv2_wifi_disconnect: '와이파이 연결 끊기', + codingboxv2_wifi_is_connected: '와이파이가 연결되었는가?', + codingboxv2_webpanel_reset: '웹 패널 초기화하기', + codingboxv2_webpanel_title: '웹 패널 제목을 %1 로 정하기', + codingboxv2_webpanel_readout: '웹 패널에 %1 이름의 %2 값 표시창 추가하기', + codingboxv2_webpanel_url: '웹 패널 주소', + }, + Helper: { + codingboxv2_set_led: '선택한 LED를 켜거나 끕니다.', + codingboxv2_get_light: '조도 센서에서 측정한 밝기 값을 확인합니다.', + codingboxv2_get_sound: '사운드 센서에서 측정한 소리 값을 확인합니다.', + codingboxv2_get_pir: '인체 감지 센서에서 사람의 움직임이 감지되었는지 확인합니다.', + codingboxv2_get_distance: '초음파 센서에서 측정한 거리를 센티미터(cm) 단위로 확인합니다.', + codingboxv2_get_joystick: '방향 버튼의 선택한 방향 값을 확인합니다.', + codingboxv2_set_servo: '서보 모터의 각도를 지정한 각도로 정합니다.', + codingboxv2_play_note: '스피커에서 선택한 음을 지정한 박자 동안 연주합니다.', + codingboxv2_oled_print: 'OLED의 지정한 X, Y 위치에 선택한 크기로 내용을 출력합니다.', + codingboxv2_oled_icon: 'OLED에 선택한 아이콘을 표시합니다.', + codingboxv2_oled_clear: 'OLED에 표시된 내용을 모두 지웁니다.', + codingboxv2_get_temperature: '온도 센서에서 측정한 온도를 섭씨(℃) 단위로 확인합니다.', + codingboxv2_get_humidity: '습도 센서에서 측정한 습도를 퍼센트(%) 단위로 확인합니다.', + codingboxv2_get_pressure: '기압 센서에서 측정한 기압을 헥토파스칼(hPa) 단위로 확인합니다.', + codingboxv2_get_altitude: '기압 센서를 이용하여 측정한 고도를 미터(m) 단위로 확인합니다.', + codingboxv2_set_fan_power: '모터 팬의 세기를 -100에서 100 사이의 값으로 정합니다.', + codingboxv2_fan_off: '모터 팬의 작동을 멈춥니다.', + // codingboxv2_get_compass_x: '나침반 센서의 X축 값을 확인합니다.', + // codingboxv2_get_compass_y: '나침반 센서의 Y축 값을 확인합니다.', + // codingboxv2_get_compass_z: '나침반 센서의 Z축 값을 확인합니다.', + codingboxv2_get_heading: '나침반 센서에서 측정한 방향을 각도(°)로 확인합니다.', + codingboxv2_is_button_pressed: '선택한 버튼이 눌렸는지 확인합니다.', + codingboxv2_get_rfid: 'RFID에서 인식한 카드 또는 태그의 ID를 확인합니다.', + codingboxv2_set_rainbow_all: '모든 레인보우 LED를 선택한 색으로 정합니다.', + codingboxv2_set_rainbow_one: '선택한 번호의 레인보우 LED 색을 지정한 색으로 정합니다.', + codingboxv2_set_rainbow_one_rgb: '선택한 번호의 레인보우 LED 색을 R, G, B 값으로 정합니다.', + codingboxv2_set_rainbow_list: '12개의 레인보우 LED 색을 각각 지정한 색으로 정합니다.', + codingboxv2_clear_rainbow: '모든 레인보우 LED를 끕니다.', + codingboxv2_wifi_connect: '입력한 와이파이 이름과 비밀번호를 사용하여 와이파이에 연결합니다.', + codingboxv2_wifi_disconnect: '현재 연결된 와이파이의 연결을 끊습니다.', + codingboxv2_wifi_is_connected: '와이파이가 연결되어 있는지 확인합니다.', + codingboxv2_webpanel_reset: '웹 패널의 설정과 표시 항목을 초기화합니다.', + codingboxv2_webpanel_title: '웹 패널에 표시할 제목을 정합니다.', + codingboxv2_webpanel_readout: '웹 패널에 지정한 이름과 값을 표시하는 값 표시창을 추가합니다.', + codingboxv2_webpanel_url: '웹 패널에 접속할 수 있는 주소를 확인합니다.', + }, + }, + en: { + template: { + codingboxv2_set_led: 'set %1 LED to %2', + codingboxv2_get_light: 'light sensor value', + codingboxv2_get_sound: 'sound sensor value', + codingboxv2_get_pir: 'PIR motion detected?', + codingboxv2_get_distance: 'ultrasonic distance(cm)', + codingboxv2_get_joystick: 'joystick %1 value', + codingboxv2_set_servo: 'set servo angle to %1 degrees', + codingboxv2_play_note: 'play %1 note for %2 beats', + codingboxv2_oled_print: 'print %4 on OLED at X %1 Y %2 size %3', + codingboxv2_oled_icon: 'show %1 icon on OLED', + codingboxv2_oled_clear: 'clear OLED display', + codingboxv2_get_temperature: 'temperature(℃)', + codingboxv2_get_humidity: 'humidity(%)', + codingboxv2_get_pressure: 'pressure(hPa)', + codingboxv2_get_altitude: 'altitude(m)', + codingboxv2_set_fan_power: 'set motor fan power to %1 (-100 ~ 100)', + codingboxv2_fan_off: 'turn off motor fan', + // codingboxv2_get_compass_x: 'compass X value', + // codingboxv2_get_compass_y: 'compass Y value', + // codingboxv2_get_compass_z: 'compass Z value', + codingboxv2_get_heading: 'compass heading(°)', + codingboxv2_is_button_pressed: 'is %1 button pressed?', + codingboxv2_get_rfid: 'RFID ID', + codingboxv2_set_rainbow_all: 'set all rainbow LEDs to %1', + codingboxv2_set_rainbow_one: 'set rainbow LED %1 to %2', + codingboxv2_set_rainbow_one_rgb: 'set rainbow LED %1 to R %2 G %3 B %4', + codingboxv2_set_rainbow_list: 'set rainbow LED colors to %1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11 %12', + codingboxv2_clear_rainbow: 'turn off all rainbow LEDs', + codingboxv2_wifi_connect: 'connect to Wi-Fi %1 with password %2', + codingboxv2_wifi_disconnect: 'disconnect Wi-Fi', + codingboxv2_wifi_is_connected: 'is Wi-Fi connected?', + codingboxv2_webpanel_reset: 'reset web panel', + codingboxv2_webpanel_title: 'set web panel title to %1', + codingboxv2_webpanel_readout: 'add %2 readout named %1 to web panel', + codingboxv2_webpanel_url: 'web panel address', + }, + Helper: { + codingboxv2_set_led: 'Turns the selected LED on or off.', + codingboxv2_get_light: 'Gets the brightness value measured by the light sensor.', + codingboxv2_get_sound: 'Gets the sound value measured by the sound sensor.', + codingboxv2_get_pir: 'Checks whether human movement is detected by the motion sensor.', + codingboxv2_get_distance: 'Gets the distance measured by the ultrasonic sensor in centimeters (cm).', + codingboxv2_get_joystick: 'Gets the value of the selected direction button.', + codingboxv2_set_servo: 'Sets the servo motor to the specified angle.', + codingboxv2_play_note: 'Plays the selected note through the speaker for the specified beat.', + codingboxv2_oled_print: 'Displays content at the specified X and Y position on the OLED using the selected size.', + codingboxv2_oled_icon: 'Displays the selected icon on the OLED.', + codingboxv2_oled_clear: 'Clears all content displayed on the OLED.', + codingboxv2_get_temperature: 'Gets the temperature measured by the temperature sensor in degrees Celsius (℃).', + codingboxv2_get_humidity: 'Gets the humidity measured by the humidity sensor as a percentage (%).', + codingboxv2_get_pressure: 'Gets the atmospheric pressure measured by the pressure sensor in hectopascals (hPa).', + codingboxv2_get_altitude: 'Gets the altitude measured using the pressure sensor in meters (m).', + codingboxv2_set_fan_power: 'Sets the motor fan power to a value between -100 and 100.', + codingboxv2_fan_off: 'Stops the motor fan.', + // codingboxv2_get_compass_x: 'Gets the X-axis value of the compass sensor.', + // codingboxv2_get_compass_y: 'Gets the Y-axis value of the compass sensor.', + // codingboxv2_get_compass_z: 'Gets the Z-axis value of the compass sensor.', + codingboxv2_get_heading: 'Gets the direction measured by the compass sensor in degrees (°).', + codingboxv2_is_button_pressed: 'Checks whether the selected button is pressed.', + codingboxv2_get_rfid: 'Gets the ID of the card or tag detected by the RFID reader.', + codingboxv2_set_rainbow_all: 'Sets all rainbow LEDs to the selected color.', + codingboxv2_set_rainbow_one: 'Sets the selected rainbow LED to the specified color.', + codingboxv2_set_rainbow_one_rgb: 'Sets the selected rainbow LED using R, G, and B values.', + codingboxv2_set_rainbow_list: 'Sets each of the 12 rainbow LEDs to the specified color.', + codingboxv2_clear_rainbow: 'Turns off all rainbow LEDs.', + codingboxv2_wifi_connect: 'Connects to Wi-Fi using the entered Wi-Fi name and password.', + codingboxv2_wifi_disconnect: 'Disconnects from the currently connected Wi-Fi network.', + codingboxv2_wifi_is_connected: 'Checks whether Wi-Fi is connected.', + codingboxv2_webpanel_reset: 'Resets the web panel settings and display items.', + codingboxv2_webpanel_title: 'Sets the title displayed on the web panel.', + codingboxv2_webpanel_readout: 'Adds a readout to the web panel that displays the specified name and value.', + codingboxv2_webpanel_url: 'Gets the address used to access the web panel.', + }, + }, + }; + } + + getBlocks = function () { + return { + // 신호등 LED + codingboxv2_set_led: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_set_led, + params: [ + { + type: 'Dropdown', + options: this.ledColorMenu, + value: 'red', + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + { + type: 'Dropdown', + options: this.ledStateMenu, + value: '1', + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_led', + isNotFor: ['codingboxv2'], + def: { + params: ['red', '1', null], + type: 'codingboxv2_set_led', + }, + paramsKeyMap: { + COLOR: 0, + STATE: 1, + }, + func: (sprite, script) => { + const color = script.getField('COLOR', script); + const state = script.getField('STATE', script); + + this.requestCommand( + this.functionKeys.SET_LED, + `${color},${state}` + ); + }, + }, + + // 조도 센서 + codingboxv2_get_light: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + statements: [], + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_light', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.light || 0; + }, + }, + + // 사운드 센서 + codingboxv2_get_sound: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + statements: [], + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_sound', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.sound || 0; + }, + }, + + // 인체감지 센서 + codingboxv2_get_pir: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_boolean_field', + statements: [], + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_pir', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.pir == 1; + }, + }, + + // 초음파 센서 + codingboxv2_get_distance: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + statements: [], + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_distance', + }, + paramsKeyMap: {}, + func: () => { + return Math.round(Entry.hw.portData.distance || 0); + }, + }, + + // 방향 버튼 값 + codingboxv2_get_joystick: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + statements: [], + template: Lang.template.codingboxv2_get_joystick, + params: [ + { + type: 'Dropdown', + options: this.joystickAxisMenu, + value: 'x', + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + ], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + params: ['x'], + type: 'codingboxv2_get_joystick', + }, + paramsKeyMap: { + AXIS: 0, + }, + func: (sprite, script) => { + const axis = script.getField('AXIS', script); + + if (axis === 'x') { + return Entry.hw.portData.joyX || 0; + } + + return Entry.hw.portData.joyY || 0; + }, + }, + + // 서보 모터 + codingboxv2_set_servo: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 90, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_output', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'text', + params: ['90'], + }, + ], + type: 'codingboxv2_set_servo', + }, + paramsKeyMap: { + ANGLE: 0, + }, + func: (sprite, script) => { + let angle = script.getNumberValue('ANGLE'); + + angle = Math.max(0, Math.min(180, angle)); + + this.requestCommand( + this.functionKeys.SERVO, + angle + ); + }, + }, + + // 스피커 + codingboxv2_play_note: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + params: [ + { + type: 'Dropdown', + options: this.noteMenu, + value: '262', + fontSize: 11, + }, + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 1, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_output', + isNotFor: ['codingboxv2'], + def: { + params: [ + '262', + { + type: 'text', + params: ['1'], + }, + null, + ], + type: 'codingboxv2_play_note', + }, + paramsKeyMap: { + NOTE: 0, + BEAT: 1, + }, + func: (sprite, script) => { + const note = script.getField('NOTE', script); + const beat = script.getNumberValue('BEAT'); + + this.requestCommand( + this.functionKeys.TONE, + `${note},${beat}` + ); + }, + }, + + // OLED 출력 + codingboxv2_oled_print: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 20, + }, + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 20, + }, + { + type: 'Dropdown', + options: [ + ['작게', '1'], + ['크게', '2'], + ], + value: '2', // 크게를 기본값으로 + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + { + type: 'Block', + accept: 'string', + defaultType: 'text', + value: 'Hello', + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_oled', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'number', + params: ['20'], + }, + { + type: 'number', + params: ['20'], + }, + '2', + { + type: 'text', + params: ['Hello'], + }, + null, + ], + type: 'codingboxv2_oled_print', + }, + paramsKeyMap: { + X: 0, + Y: 1, + SIZE: 2, + TEXT: 3, + }, + func: (sprite, script) => { + const x = script.getNumberValue('X') || 0; + const y = script.getNumberValue('Y') || 0; + const size = script.getField('SIZE', script); + + const text = script.getStringValue('TEXT') || ''; + + this.requestCommand( + this.functionKeys.OLED_PRINT, + `${x},${y},${size},${text}` + ); + }, + }, + + // OLED 아이콘 표시 + codingboxv2_oled_icon: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_oled_icon, + params: [ + { + type: 'Dropdown', + options: this.oledIconMenu, + value: 'smile', + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_oled', + isNotFor: ['codingboxv2'], + def: { + params: ['smile', null], + type: 'codingboxv2_oled_icon', + }, + paramsKeyMap: { + ICON: 0, + }, + func: (sprite, script) => { + const icon = script.getField('ICON', script); + + this.requestCommand( + this.functionKeys.OLED_ICON, + icon + ); + }, + }, + + // OLED 지우기 + codingboxv2_oled_clear: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + params: [ + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_oled', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_oled_clear', + }, + paramsKeyMap: {}, + func: () => { + this.requestCommand( + this.functionKeys.OLED_CLEAR, + 0 + ); + }, + }, + + // 온도 + codingboxv2_get_temperature: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { type: 'codingboxv2_get_temperature' }, + paramsKeyMap: {}, + func: () => Entry.hw.portData.temperature || 0, + }, + + // 습도 + codingboxv2_get_humidity: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { type: 'codingboxv2_get_humidity' }, + paramsKeyMap: {}, + func: () => Entry.hw.portData.humidity || 0, + }, + + // 기압 + codingboxv2_get_pressure: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { type: 'codingboxv2_get_pressure' }, + paramsKeyMap: {}, + func: () => Entry.hw.portData.pressure || 0, + }, + + // 고도 + codingboxv2_get_altitude: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { type: 'codingboxv2_get_altitude' }, + paramsKeyMap: {}, + func: () => Entry.hw.portData.altitude || 0, + }, + + // 모터 팬 + codingboxv2_set_fan_power: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 100, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_fan', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'number', + params: ['100'], + }, + null, + ], + type: 'codingboxv2_set_fan_power', + }, + paramsKeyMap: { + POWER: 0, + }, + func: (sprite, script) => { + let power = script.getNumberValue('POWER'); + power = Math.max(-100, Math.min(100, power)); + + this.requestCommand(this.functionKeys.FAN, power); + }, + }, + + // 모터 팬 끄기 + codingboxv2_fan_off: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_fan_off, + params: [], + events: {}, + class: 'codingboxv2_fan', + isNotFor: ['codingboxv2'], + def: { + params: [], + type: 'codingboxv2_fan_off', + }, + paramsKeyMap: {}, + func: () => { + this.requestCommand( + this.functionKeys.FAN, + 0 + ); + }, + }, + + /* + // 나침반 X축 + codingboxv2_get_compass_x: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_compass_x', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.compassX || 0; + }, + }, + + // 나침반 Y축 + codingboxv2_get_compass_y: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_compass_y', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.compassY || 0; + }, + }, + + // 나침반 Z축 + codingboxv2_get_compass_z: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_compass_z', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.compassZ || 0; + }, + }, + */ + + // 나침반 방향 + codingboxv2_get_heading: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_heading', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.heading || 0; + }, + }, + + // 버튼 + codingboxv2_is_button_pressed: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_boolean_field', + statements: [], + params: [ + { + type: 'Dropdown', + options: this.buttonColorMenu, + value: '1', + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + ], + events: {}, + class: 'codingboxv2_led', + isNotFor: ['codingboxv2'], + def: { + params: ['1'], + type: 'codingboxv2_is_button_pressed', + }, + paramsKeyMap: { + BUTTON: 0, + }, + func: (sprite, script) => { + const selectedButton = Number(script.getField('BUTTON', script)); + const currentButton = Number(Entry.hw.portData.button || 0); + + return currentButton === selectedButton; + }, + }, + + // RFID + codingboxv2_get_rfid: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + params: [], + events: {}, + class: 'codingboxv2_sensor', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_get_rfid', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.rfid || ''; + }, + }, + + // 레인보우 LED 전체 + codingboxv2_set_rainbow_all: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_set_rainbow_all, + params: [ + { + type: 'Color', + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_rainbow', + isNotFor: ['codingboxv2'], + def: { + params: [ + '#ff0000', + null, + ], + type: 'codingboxv2_set_rainbow_all', + }, + paramsKeyMap: { + COLOR: 0, + }, + func: (sprite, script) => { + const color = script.getField('COLOR', script) || '#ff0000'; + + const r = parseInt(color.substring(1, 3), 16); + const g = parseInt(color.substring(3, 5), 16); + const b = parseInt(color.substring(5, 7), 16); + + this.requestCommand( + this.functionKeys.RAINBOW_ALL, + `${r},${g},${b}` + ); + }, + }, + + // 레인보우 LED 개별 색 + codingboxv2_set_rainbow_list: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_set_rainbow_list, + params: [ + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { type: 'Color' }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_rainbow', + isNotFor: ['codingboxv2'], + def: { + params: [ + '#ff0000', // 1 + '#ff4600', // 2 + '#ff8b00', // 3 + '#ffd100', // 4 + '#e8ff00', // 5 + '#8cff00', // 6 + '#31ff00', // 7 + '#00ff73', // 8 + '#00e6d5', // 9 + '#008cff', // 10 + '#3100ff', // 11 + '#8b00ff', // 12 + null, + ], + type: 'codingboxv2_set_rainbow_list', + }, + paramsKeyMap: { + COLOR1: 0, + COLOR2: 1, + COLOR3: 2, + COLOR4: 3, + COLOR5: 4, + COLOR6: 5, + COLOR7: 6, + COLOR8: 7, + COLOR9: 8, + COLOR10: 9, + COLOR11: 10, + COLOR12: 11, + }, + func: (sprite, script) => { + const colors = []; + + for (let i = 1; i <= 12; i++) { + const color = + script.getField(`COLOR${i}`, script) || '#ff0000'; + + const r = parseInt(color.substring(1, 3), 16); + const g = parseInt(color.substring(3, 5), 16); + const b = parseInt(color.substring(5, 7), 16); + + colors.push(`${r},${g},${b}`); + } + + this.requestCommand( + this.functionKeys.RAINBOW_LIST, + colors.join('|') + ); + }, + }, + + // 레인보우 LED 하나 + codingboxv2_set_rainbow_one: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_set_rainbow_one, + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 1, + }, + { + type: 'Color', + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_rainbow', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'number', + params: ['1'], + }, + '#ff0000', + null, + ], + type: 'codingboxv2_set_rainbow_one', + }, + paramsKeyMap: { + NUMBER: 0, + COLOR: 1, + }, + func: (sprite, script) => { + let number = script.getNumberValue('NUMBER'); + number = Math.max(1, Math.min(12, number)); + + const color = script.getField('COLOR', script) || '#ff0000'; + + const r = parseInt(color.substring(1, 3), 16); + const g = parseInt(color.substring(3, 5), 16); + const b = parseInt(color.substring(5, 7), 16); + + this.requestCommand( + this.functionKeys.RAINBOW_ONE, + `${number},${r},${g},${b}` + ); + }, + }, + + // 레인보우 LED 하나 RGB 값으로 설정 + codingboxv2_set_rainbow_one_rgb: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_set_rainbow_one_rgb, + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 1, + }, + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 255, + }, + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 0, + }, + { + type: 'Block', + accept: 'string', + defaultType: 'number', + value: 0, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_rainbow', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'number', + params: ['1'], + }, + { + type: 'number', + params: ['255'], + }, + { + type: 'number', + params: ['0'], + }, + { + type: 'number', + params: ['0'], + }, + null, + ], + type: 'codingboxv2_set_rainbow_one_rgb', + }, + paramsKeyMap: { + NUMBER: 0, + RED: 1, + GREEN: 2, + BLUE: 3, + }, + func: (sprite, script) => { + let number = script.getNumberValue('NUMBER'); + let red = script.getNumberValue('RED'); + let green = script.getNumberValue('GREEN'); + let blue = script.getNumberValue('BLUE'); + + number = Math.round(number); + red = Math.round(red); + green = Math.round(green); + blue = Math.round(blue); + + number = Math.max(1, Math.min(12, number)); + red = Math.max(0, Math.min(255, red)); + green = Math.max(0, Math.min(255, green)); + blue = Math.max(0, Math.min(255, blue)); + + this.requestCommand( + this.functionKeys.RAINBOW_ONE, + `${number},${red},${green},${blue}` + ); + }, + }, + + // 레인보우 LED 모두 끄기 + codingboxv2_clear_rainbow: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_clear_rainbow, + params: [], + events: {}, + class: 'codingboxv2_rainbow', + isNotFor: ['codingboxv2'], + def: { + params: [], + type: 'codingboxv2_clear_rainbow', + }, + paramsKeyMap: {}, + func: () => { + this.requestCommand( + this.functionKeys.RAINBOW_CLEAR, + 0 + ); + }, + }, + + // 와이파이 연결 + codingboxv2_wifi_connect: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_wifi_connect, + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'text', + value: 'WiFi', + }, + { + type: 'Block', + accept: 'string', + defaultType: 'text', + value: '12345678', + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_wifi', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'text', + params: ['WiFi'], + }, + { + type: 'text', + params: ['12345678'], + }, + null, + ], + type: 'codingboxv2_wifi_connect', + }, + paramsKeyMap: { + SSID: 0, + PASSWORD: 1, + }, + func: (sprite, script) => { + if (!script.wifiWaitStartTime) { + const ssid = script.getStringValue('SSID') || ''; + const password = script.getStringValue('PASSWORD') || ''; + + Entry.hw.portData.wifiConnecting = 1; + Entry.hw.portData.wifiConnected = 0; + + this.requestCommand( + this.functionKeys.WIFI_CONNECT, + `${ssid},${password}` + ); + + script.wifiWaitStartTime = Date.now(); + + return script; + } + + const elapsed = Date.now() - script.wifiWaitStartTime; + + if (elapsed < 5000) { + return script; + } + + delete script.wifiWaitStartTime; + return; + }, + }, + // 와이파이 연결 끊기 + codingboxv2_wifi_disconnect: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_wifi_disconnect, + params: [], + events: {}, + class: 'codingboxv2_wifi', + isNotFor: ['codingboxv2'], + def: { + params: [], + type: 'codingboxv2_wifi_disconnect', + }, + paramsKeyMap: {}, + func: () => { + this.requestCommand( + this.functionKeys.WIFI_DISCONNECT, + 0 + ); + }, + }, + + // 와이파이 연결 상태 + codingboxv2_wifi_is_connected: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_boolean_field', + statements: [], + params: [], + events: {}, + class: 'codingboxv2_wifi', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_wifi_is_connected', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.wifiConnected == 1; + }, + }, + + // 웹 패널 초기화 + codingboxv2_webpanel_reset: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_webpanel_reset, + params: [], + events: {}, + class: 'codingboxv2_webpanel', + isNotFor: ['codingboxv2'], + def: { + params: [], + type: 'codingboxv2_webpanel_reset', + }, + paramsKeyMap: {}, + func: () => { + this.requestCommand( + this.functionKeys.WEBPANEL_RESET, + 0 + ); + }, + }, + + // 웹 패널 제목 + codingboxv2_webpanel_title: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_webpanel_title, + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'text', + value: 'PLAYCODING BOX V2.0 Web Panel', + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_webpanel', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'text', + params: ['PLAYCODING BOX V2.0 Web Panel'], + }, + null, + ], + type: 'codingboxv2_webpanel_title', + }, + paramsKeyMap: { + TITLE: 0, + }, + func: (sprite, script) => { + const title = + script.getStringValue('TITLE') || 'PLAYCODING BOX V2.0 Web Panel'; + + this.requestCommand( + this.functionKeys.WEBPANEL_TITLE, + title + ); + }, + }, + + // 웹 패널 값 표시 + codingboxv2_webpanel_readout: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic', + statements: [], + template: Lang.template.codingboxv2_webpanel_readout, + params: [ + { + type: 'Block', + accept: 'string', + defaultType: 'text', + value: 'Temperature', + }, + { + type: 'Dropdown', + options: this.webPanelSensorMenu, + value: 'temperature', + fontSize: 11, + bgColor: EntryStatic.colorSet.block.darken.HARDWARE, + arrowColor: EntryStatic.colorSet.arrow.default.HARDWARE, + }, + { + type: 'Indicator', + img: 'block_icon/hardware_icon.svg', + size: 12, + }, + ], + events: {}, + class: 'codingboxv2_webpanel', + isNotFor: ['codingboxv2'], + def: { + params: [ + { + type: 'text', + params: ['Temperature'], + }, + 'temperature', + null, + ], + type: 'codingboxv2_webpanel_readout', + }, + paramsKeyMap: { + LABEL: 0, + SENSOR: 1, + }, + func: (sprite, script) => { + const label = + script.getStringValue('LABEL') || ''; + + const sensor = + script.getField('SENSOR', script); + + this.requestCommand( + this.functionKeys.WEBPANEL_READOUT, + `${label},${sensor}` + ); + }, + }, + + // 웹 패널 주소 + codingboxv2_webpanel_url: { + color: EntryStatic.colorSet.block.default.HARDWARE, + outerLine: EntryStatic.colorSet.block.darken.HARDWARE, + fontColor: '#ffffff', + skeleton: 'basic_string_field', + statements: [], + params: [], + events: {}, + class: 'codingboxv2_webpanel', + isNotFor: ['codingboxv2'], + def: { + type: 'codingboxv2_webpanel_url', + }, + paramsKeyMap: {}, + func: () => { + return Entry.hw.portData.webPanelUrl || ''; + }, + }, + + // 블록 공간 여백 + codingboxv2_bottom_space: { + skeleton: 'basic_text', + color: EntryStatic.colorSet.common.TRANSPARENT, + fontColor: 'transparent', + template: '%1', + params: [ + { + type: 'Text', + text: ' ', + color: 'transparent', + }, + ], + def: { + type: 'codingboxv2_bottom_space', + }, + class: 'codingboxv2', + isNotFor: ['codingboxv2'], + events: {}, + }, + }; + }; +})(); + +module.exports = Entry.CodingBoxV2; \ No newline at end of file diff --git a/src/playground/blocks/hardware/block_davinci.js b/src/playground/blocks/hardware/block_davinci.js index 4444bb4bfc..3dd3ef6b4c 100644 --- a/src/playground/blocks/hardware/block_davinci.js +++ b/src/playground/blocks/hardware/block_davinci.js @@ -2,7 +2,6 @@ const _set = require('lodash/set'); const _get = require('lodash/get'); -const _merge = require('lodash/merge'); Entry.Davinci = new class Davinci { constructor() { @@ -73,9 +72,7 @@ Entry.Davinci = new class Davinci { scope.timeFlag = 1; this.nowBlockId = blockId; this.blockIds[blockId] = false; - _merge(Entry.hw.sendQueue, { - [blockId]: data, - }); + Entry.hw.sendQueue[blockId] = data; Entry.hw.update(); setTimeout(() => { scope.timeFlag = 0; diff --git a/src/playground/blocks/hardware/block_microbit.js b/src/playground/blocks/hardware/block_microbit.js index e15a89f7c2..c4f52bd368 100644 --- a/src/playground/blocks/hardware/block_microbit.js +++ b/src/playground/blocks/hardware/block_microbit.js @@ -2,7 +2,6 @@ const _set = require('lodash/set'); const _get = require('lodash/get'); -const _merge = require('lodash/merge'); Entry.Microbit = new (class Microbit { constructor() { @@ -73,9 +72,7 @@ Entry.Microbit = new (class Microbit { scope.timeFlag = 1; this.nowBlockId = blockId; this.blockIds[blockId] = false; - _merge(Entry.hw.sendQueue, { - [blockId]: data, - }); + Entry.hw.sendQueue[blockId] = data; Entry.hw.update(); setTimeout(() => { scope.timeFlag = 0; diff --git a/src/playground/scope.js b/src/playground/scope.js index bcf714db76..78d758428e 100644 --- a/src/playground/scope.js +++ b/src/playground/scope.js @@ -27,7 +27,10 @@ class Scope { static _reservedKeywords = new Set(['__proto__']); filterReservedKeywords(param) { - return Scope._reservedKeywords.has(param) ? '' : param; + // 배열/객체를 키로 쓸 때 toString 변환(예: ["__proto__"] → "__proto__")으로 + // 예약어를 우회하는 것을 차단한다. + const normalized = typeof param === 'object' && param !== null ? String(param) : param; + return Scope._reservedKeywords.has(normalized) ? '' : param; } getParams() { diff --git a/src/util/utils.js b/src/util/utils.js index 4864044255..b2e6176d62 100644 --- a/src/util/utils.js +++ b/src/util/utils.js @@ -479,6 +479,27 @@ Entry.Utils.generateId = function () { return `0000${((Math.random() * Math.pow(36, 4)) << 0).toString(36)}`.substr(-4); }; +/** + * Sortable 위젯의 setData 등 리렌더 콜백 실행 중에도 + * 내부 스크롤 컨테이너(.rcs-inner-container)의 스크롤 위치를 유지한다. + * @param {HTMLElement} containerEl Sortable이 마운트된 컨테이너 + * @param {Function} callback 리스트를 갱신하는 동기 콜백 + */ +Entry.Utils.runWithScrollPreserved = function (containerEl, callback) { + const getScrollEl = () => containerEl && containerEl.querySelector('.rcs-inner-container'); + const before = getScrollEl(); + const top = before ? before.scrollTop : 0; + const left = before ? before.scrollLeft : 0; + + callback(); + + const after = getScrollEl(); + if (after) { + after.scrollTop = top; + after.scrollLeft = left; + } +}; + Entry.Utils.randomColor = function () { const letters = '0123456789ABCDEF'; let color = '#';