Scratch二次开发8:背景、角色、造型、声音后台管理

时隔两年,再次接着写

为公共事业做贡献,做了个开源版本:scratch.lite

开源版本带MySQL后台服务器,功能:注册、登录、保存作品、分享、修改作品名称、保存作品缩略图。

有兴趣的朋友可以去下载参考:lite: 一个轻量级的Scratch编程分享平台:注册登录、作品创作、作品管理、素材管理、用户管理,作品点赞、收藏、分享。

Scratch二次开发的纯技术交流QQ群:115224892/914159821

(有搭建手册)

这次我们来聊聊Scratch的素材管理定制方面的内容(扩展管理后续再放上来吧)

整个Scratch作品,基本上就是围绕背景、角色、造型、声音这4类素材来操作的。

但官方开源时,已直接把这几类素材直接放在Scratch中处理了。

要求不高的话,这也无所谓。

如果能放在后台去管理,大体上会有三个好处:

1、减小lib.min.js文件的大小;

2、可以灵活的管理各类素材(各类节假日等时机,可以放上应景的素材);

3、每次打开素材选择窗口时,就不必要一次性加载了(默认的是会从服务器上下载全部素材的。举例:当打开背景选择窗口时,会直接把全部的背景图片都下载下来,严重浪费带宽,如果网络慢点的话,同时也会影响体验)。

指定默认作品时,也能做些应景的事,或是每机构都可以有自己的默认作品,这样用户一打开或是新建作品时,就直接使用默认作品模板了(开源版本中,已实现了这个功能,后续也可以聊一聊)。

正题:Scratch背景 后台管理功能的实现

下面将从三个方面来说:

1、Scratch中的源代码;

2、Scratch中的接口;

3、服务器端的源代码。

  • 一、Scratch中的关键源代码

a.背景选择窗口的入口文件:/src/containers/backdrop-library.jsx

此文件仅仅只是一个入口,它会在此准备好背景的分类、背景数据。

b.它后面会直接调用/src/components/library/library.jsx来具体执行(这个文件官方原版本:教程、造型、背景、角色、声音、扩展等界面共用。此文件耦合太紧了,很是让人头疼的)。

c.library.jsx会实现一个Modal,并会使用/src/containers/library-item.jsx显示具体的背景(这个Modal及LibraryItem的属性就乱的很(狠)啊,毕竟是好几个不同的东西共用的。)

本人已把各娄素材选择窗口,已从上面的共用文件中分离出来了(舒服... ...)

在贴代码前,说说都做了些什么:

1、直接从服务器获取背景的分类数据;

2、直接从服务器上获取背景数据(采用了流式加载:第一次只加载30来个,后续当用户鼠标滚动到底部时,会再次加载30来个,直到加载完全部的背景);

3、优化了原搜索功能:可以在不同分类下搜索;

4、直接把原backdrop-library.jsx、library.jsx、LibraryItem直接在一次性搞定了(虽然少了些共用性及同代码片段的复用,但确实简洁、舒服多了)。

上代码,上完整的源代码,上香香的源代码:

// 背景选择窗口
import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
import VM from 'scratch-vm';
import classNames from 'classnames';
import Filter from '../components/filter/filter.jsx';
import Divider from '../components/divider/divider.jsx';
import TagButton from './tag-button.jsx';
import Spinner from '../components/spinner/spinner.jsx';
import {Card} from 'antd';
const {Meta} = Card;

import Modal from '../containers/modal.jsx'; // 选择窗口组件
import {getBackdropLibrary} from '../session-api';
import styles from './project-library.css';

class BackdropLibrary extends React.Component {
    constructor (props) {
        super(props);
        bindAll(this, [
            'handleClose',
            'handleFilterChange',
            'handleFilterKeyUp',
            'handleFilterClear',
            'handleTagClick',
            'handleOnScroll',
            'handleItemSelect',
            'setFilteredDataRef',
            'handleGetMoreItem'
        ]);
        this.state = {
            isSearch: false, // 开始搜索
            isEnterSearched: false, // 是否已经通过回车搜索过:在未回车搜索过时,直接清空搜索框时,不必去取分类的全部
            filterQuery: '',
            selectedTag: 0,
            tagList: [], // 分类标签
            itemList: [], // itemList.length 已获取的背景数,用于流方式加载
            isOver: false, // 是否已获取了分类下的全部背景
            loading: true // 是否已加载完
        };
    }
    componentWillMount (){
        this.getItemList(true, 1); // 加载完后,第一次获取背景列表

    }
    componentDidUpdate (prevProps, prevState) {
        if (prevState.selectedTag !== this.state.selectedTag || this.state.isSearch) {
            this.getItemList(true, 0);
        }
    }
    // 获取背景列表:newList:新列表, newTag:1:同时获取分类数据
    getItemList (newList, newTag) {
        // console.info("当前状态值:", this.state);
        if (newList) { // 只有滚动到底时,才不清空已获取的背景列表
            this.setState({itemList: [], isOver: false});
        }

        if (this.state.isSearch) {
            this.setState({isSearch: false});
        }

        if (this.state.isOver) {
            return;
        }
        
        // 组件获取条件 tag:是否获取分类; f: 搜索字符串; t: 分类; l: 已经获取的背景数; n: 每次获取的背景数,默认为32个
        const searchParam = `tag=${newTag}&&f=${this.state.filterQuery}&&t=${this.state.selectedTag}&&l=${this.state.itemList.length}&n=32`;
        getBackdropLibrary(searchParam).then(res => {
            // console.error("getBackdropLibrary", res);
            this.setState({loading: false});
            
            if (1 == newTag) {
                this.setState({tagList: res.tags});
            }

            if (res.data.length < 32) {
                this.setState({isOver: true});
            }

            if (newList){
                this.setState({itemList: res.data});
            } else {
                this.setState({itemList: this.state.itemList.concat(res.data)});
            }
        });
    }

    
    // 关闭窗口
    handleClose () {
        this.props.onRequestClose();
    }

    // 切换标签
    handleTagClick (id) {
        if (this.state.selectedTag != id){
            this.setState({filterQuery: '', isEnterSearched: false, selectedTag: id, itemList: [], isOver: false, loading: true});
        }
    }

    // 搜索:输入字符串
    handleFilterChange (event) {
        this.setState({filterQuery: event.target.value});
    }
    // 搜索:按回车时开始搜索
    handleFilterKeyUp (event) {
        if (event.keyCode === 13 && event.target.value.length > 0) {
            this.setState({isSearch: true, isEnterSearched: true, itemList: [], isOver: false, loading: true}); // 触发搜索
        }
    }
    // 搜索:清空字符串
    handleFilterClear () {
        this.setState({filterQuery: ''});
        if (this.state.isEnterSearched) { // 在未回车搜索过时,直接清空搜索框时,不必去取分类的全部
            this.setState({isSearch: true, isEnterSearched: false, itemList: [], isOver: false, loading: true});
        }
    }

    // 绑定组件变量,为监听页面滚动
    setFilteredDataRef (ref) {
        this.filteredDataRef = ref;
    }
    // 监听页面滚动,到底后,再次自动加载背景
    handleOnScroll () {
        if (this.filteredDataRef) {
            const contentScrollTop = this.filteredDataRef.scrollTop; // 滚动条距离顶部
            const clientHeight = this.filteredDataRef.clientHeight; // 可视区域
            const scrollHeight = this.filteredDataRef.scrollHeight; // 滚动条内容的总高度
            if (contentScrollTop + clientHeight >= scrollHeight - 10) { // 提前 10 个位置开始获取后续元素
                if (!this.state.isOver) {
                    this.getItemList(false, 0); // 继续获取数据的方法
                }
                // console.error('已到底');
            }
        }
    }
    // 手动点击按钮,继续获取数据的方法
    handleGetMoreItem () {
        if (!this.state.isOver) {
            this.getItemList(false, 0);
        }
    }

    // 打开背景
    handleItemSelect (index) {
        this.handleClose();
        const item = this.state.itemList[index];

        const vmBackdrop = {
            name: item.name,
            rotationCenterX: item.info0,
            rotationCenterY: item.info1,
            bitmapResolution: item.info2,
            skinId: null
        };

        // Do not switch to stage, just add the backdrop
        this.props.vm.addBackdrop(item.md5, vmBackdrop);
    }

    render () {
        return (
            <Modal
                fullScreen
                contentLabel="选择一个背景"
                id="backdropLibrary"
                onRequestClose={this.handleClose}
            >

                {/* 搜索、分类部分 */}
                <div className={styles.filterBar}>
                    <Filter
                        className={classNames(styles.filterBarItem, styles.filter)}
                        filterQuery={this.state.filterQuery}
                        inputClassName={styles.filterInput}
                        placeholderText="搜索"
                        onChange={this.handleFilterChange}
                        onClear={this.handleFilterClear}
                        onKeyUp={this.handleFilterKeyUp}
                    />

                    <Divider className={classNames(styles.filterBarItem, styles.divider)} />

                    <div className={styles.tagWrapper}>
                        {[{tag: "全部", id: 0}].concat(this.state.tagList).map((tag, index) => (
                            <TagButton
                                active={this.state.selectedTag === tag.id}
                                className={classNames(styles.filterBarItem, styles.tagButton)}
                                key={`tag-button-${index}`}
                                onClick={()=>this.handleTagClick(tag.id)}
                                tag={tag.tag}
                            />
                        ))}
                    </div>
                </div>

                <div
                    className={classNames(styles.libraryScrollGrid, styles.withFilterBar)}
                    ref={this.setFilteredDataRef}
                    onScrollCapture={this.handleOnScroll}
                >
                    {this.state.loading ? (
                        <div className={styles.spinnerWrapper}>
                            <Spinner
                                large
                                level="primary"
                            />
                        </div>
                    ) : (<>
                        {(this.state.itemList.length == 0)&&(
                            <div className={styles.spinnerWrapper}>
                                <div className={styles.spanBox}>空</div>
                            </div>
                        )}

                        {this.state.itemList.map((item, index) => (
                            <Card
                                hoverable
                                className={styles.ItemCard}
                                style={{width: 180, margin: 6, borderRadius: 6}}
                                cover={<img src={`/scratch/assets/${item.md5}`} style={{width: 160, height: 120, margin: 10}}/>}
                                key={index}
                                onClick={()=>this.handleItemSelect(index)}
                            >
                                <Meta title={item.name} />
                            </Card>
                        ))}
                        
                        {!this.state.isOver && (
                            <div
                                className={styles.spinnerWrapper1}
                                onClick={this.handleGetMoreItem}
                            >
                                <div className={styles.spanBox}>点我可继续</div>
                            </div>
                        )}
                    </>)}
                </div>
            </Modal>
        );
    }
}

BackdropLibrary.propTypes = {
    onRequestClose: PropTypes.func,
    vm: PropTypes.instanceOf(VM).isRequired
};

export default BackdropLibrary;

哦哦哦,本人技术男,总觉得自己做的CSS不好看,所以,这次引入了阿里的Ant Design。

(具体怎么引入Ant Design到Scratch,那又是另一个小话题了)。

上面只是选择背景的窗口,还有一个地方要注意:Scratch可以直接获取一个随机背景的,此功能有两处,所在的文件分别是:/src/containers/costume-tab.jsx与/src/containers/stage-selector.jsx,

此处源代码为:

// 下面源代码所在的文件:/src/containers/costume-tab.jsx

// 先引入接口
import {getRandomBackdrop, getRandomCostume} from '../session-api';
// 从上面也引入了getRandomCostume可以看出,此文件也同时实现的随机获取一个造型的功能

... ... 

    handleSurpriseBackdrop () {
        // 随机选择一个背景
        // 这里要特别注意,要彻底明白背景的各个属性数据的意义
        // 官方的是日积月累搞出来的,为了兼容,做了很多“坏”事
        getRandomBackdrop().then(res => {
            if (res.status == "ok"){
                const vmBackdrop = {
                    name: res.data.name,
                    md5: res.data.md5,
                    rotationCenterX: res.data.info0,
                    rotationCenterY: res.data.info1,
                    bitmapResolution: res.data.info2,
                    skinId: null
                };
                this.props.vm.addBackdrop(res.data.md5, vmBackdrop);
            }
        });

        // const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
        // const vmCostume = {
        //     name: item.name,
        //     md5: item.md5,
        //     rotationCenterX: item.info[0] && item.info[0] / 2,
        //     rotationCenterY: item.info[1] && item.info[1] / 2,
        //     bitmapResolution: item.info.length > 2 ? item.info[2] : 1,
        //     skinId: null
        // };
        // this.handleNewCostume(vmCostume);
    }
// 源代码所在的文件:/src/containers/stage-selector.jsx

// 引入接口API
import {getRandomBackdrop} from '../session-api';

... ...

    handleSurpriseBackdrop (e) {
        e.stopPropagation(); // Prevent click from falling through to selecting stage.

        getRandomBackdrop().then(res => {
            if (res.status == "ok"){
                const vmBackdrop = {
                    name: res.data.name,
                    md5: res.data.md5,
                    rotationCenterX: res.data.info0,
                    rotationCenterY: res.data.info1,
                    bitmapResolution: res.data.info2,
                    skinId: null
                };
                this.props.vm.addBackdrop(res.data.md5, vmBackdrop);
            }
        });
        // const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
        // this.addBackdropFromLibraryItem(item, false);
    }
  • 二、Scratch与服务器对接的接口源代码

主要是二个接口:

1、getBackdropLibrary:获取背景数据接口(第一次获取时,会同时取回背景的分类的数据);

2、getRandomBackdrop:随机获取一个背景接口。

上代码,上完整的源代码,上香香的源代码:

(本人直接把一些Scratch与服务器对接的API都统一放在了一个文件中,这次一并多贴点吧。现在我们只看看 getBackdropLibrary、getRandomBackdrop就好)

// 登录一:首页打开Scratch时,自动获取一次用户登录信息
module.exports.requestSession = (resolve, reject) => (
    miniFetch(resolve, reject, '/user/getSession')
);

// 登录二:提交账号、密码进行登录
module.exports.requestLogin = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/user/login', {body:data})
});

// 退出:提交账号,退出登录状态
module.exports.requestLogout = (resolve, reject, data) => (
    miniFetch(resolve, reject, '/user/logout', {body: data})
);

// 获取项目源代码
module.exports.requestProject = (resolve, reject, projectId) => (
    miniFetch(resolve, reject, `/scratch/project/${projectId}`)
);

// 保存标题
module.exports.requestSaveProjectTitle = (resolve, reject, projectId, projectTitle) => {
    miniFetch(resolve, reject, '/scratch/saveProjcetTitle', {body:`id=${projectId}&title=${projectTitle}`})
};

// 保存缩略图
module.exports.requestSaveProjectThumbnail = (resolve, reject, projectId, thumbnailBlob) => {
    miniFetch(resolve, reject, `/scratch/thumbnail/${projectId}`, {body:thumbnailBlob, headers:{'Content-Type': 'image/png'}})
};

// 分享作品 或 取消分享
module.exports.requestShareProject = (projectId, s) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, `/scratch/shareProject/${projectId}`, {body:`s=${s}`});
});

// 获取课程卡数据
module.exports.requestCousreCard = (resolve, reject, lessonId) => {
    miniFetch(resolve, reject, `/course/getcard/${lessonId}`);
};


// 获取我的作品
module.exports.getMyProjectLibrary = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getMyProjectLibrary', {body: data});
});

// 获取优秀作品
module.exports.getYxProjectLibrary = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getYxProjectLibrary', {body: data});
});

// 获取背景
module.exports.getBackdropLibrary = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getBackdropLibrary', {body: data});
});
// 随机获取背景
module.exports.getRandomBackdrop = () => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getRandomBackdrop');
});

// 获取造型
module.exports.getCostumeLibrary = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getCostumeLibrary', {body: data});
});
// 随机获取造型
module.exports.getRandomCostume = () => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getRandomCostume');
});

// 获取声音
module.exports.getSoundLibrary = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getSoundLibrary', {body: data});
});
// 随机获取声音
module.exports.getRandomSound = () => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getRandomSound');
});

// 获取角色
module.exports.getSpriteLibrary = (data) => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getSpriteLibrary', {body: data});
});
// 随机获取角色
module.exports.getRandomSprite = () => new Promise((resolve, reject) => {
    miniFetch(resolve, reject, '/scratch/getRandomSprite');
});


// 公用函数
function miniFetch(resolve, reject, uri, params){
    // uri = "https://comecode.net"+uri;

    var opts = {
        headers:{
            'Accept':'application/json,text/plain,*/*',/* 格式限制:json、文本、其他格式 */
            'Content-Type':'application/x-www-form-urlencoded'/* 请求内容类型 */
        },
        method:'post'
    }
    if (params){
        if (params.headers) {opts['headers'] =  Object.assign(opts['headers'], params.headers)}
        if (params.method) {opts["method"] = params.method}
        if (params.body)   {opts["body"] = params.body}
    }

    fetch(uri, opts).then(response=>{
        var body = response.json();
        if(response.status == 200){
            return resolve(body);
        }
        return reject(body)
    })
    .catch(err=>reject(err))
};

此处不用多说,一看便知。

  • 三、服务器端源代码

这块比较重要,也比较简单(注:开源的是NodeJS + Express实现的,如果要用其他服务器端(JAVA、PHP、Go... ...)可以参数一二):

// 获取背景
// 组件获取条件 tag:是否获取分类; f: 搜索字符串; t: 分类; l: 已经获取的背景数; n: 每次获取的背景数,默认为20个
router.post('/getBackdropLibrary', function (req, res) {
    var WHERE = '';
    if (req.body.t != 0){
      WHERE = ' AND tagId='+req.body.t;
    }

    if (req.body.f && req.body.f!=''){
      WHERE += ` AND name LIKE '%${req.body.f}%'`;
    }

    var SELECT =`SELECT id, name, md5, info0, info1, info2  FROM material_backdrop WHERE state=1 ${WHERE} ORDER BY name DESC LIMIT ${req.body.l},${req.body.n}`;
    DB.query(SELECT, function(err, Backdrop){
        if (err) {
          res.status(200).send({status:"err", data: [], tags: []});
          return;
        }

        if (req.body.tag == 0) {
          res.status(200).send({status:"ok", data: Backdrop, tags: []});
          return;
        }

        // 取一次背景分类
        SELECT =`SELECT id, tag FROM material_tags WHERE type=1 ORDER BY tag DESC`;
        DB.query(SELECT, function(err, tags){
          if (err) {
            res.status(200).send({status:"err", data: [], tags: []});
            return;
          }

          res.status(200).send({status:"ok", data: Backdrop, tags: tags});
        })        
    })
});

// 随机获取一个背景
router.post('/getRandomBackdrop', function (req, res) {
    const SELECT = `SELECT name, md5, info0, info1, info2 FROM material_backdrop` +
                  ` JOIN (SELECT MAX(id) AS maxId, MIN(id) AS minId FROM material_backdrop WHERE state=1) AS m ` +
                  ` WHERE id >= ROUND(RAND()*(m.maxId - m.minId) + m.minId) AND state=1 LIMIT 1`;
    DB.query(SELECT, function(err, B){
        if (err || B.length < 1) {
          res.status(200).send({status:"err", data: {}});
          return;
        }
  
        res.status(200).send({status:"ok", data: B[0]});       
    })
});
  • 重要:数据库结构(仅提供MySQL版本):

-- ----------------------------
-- Table structure for material_backdrop
-- ----------------------------
DROP TABLE IF EXISTS `material_backdrop`;
CREATE TABLE `material_backdrop` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `tagId` int unsigned NOT NULL COMMENT '分类ID',
  `name` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '新背景',
  `md5` char(64) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL,
  `info0` int DEFAULT '960' COMMENT '图片宽',
  `info1` int DEFAULT '720' COMMENT '图片高',
  `info2` int DEFAULT '2',
  `state` tinyint DEFAULT '1' COMMENT '状态:0停用,1正常',
  PRIMARY KEY (`id`,`tagId`)
) ENGINE=InnoDB AUTO_INCREMENT=89 DEFAULT CHARSET=utf8;

关于在服务器上怎么去增、删、改背景数据,则会后端不同而不同,大体上就是对上表的操作。

主要是要明白各属性的意义,就可以灵活的操作了。

说了这么多,也只大体上把Scratch背景怎么实现后台管理说了个大概,实在是篇幅有限,如有需要交流的,可加上面的群,一起交流。

Scratch素材,除了背景,还有造型、声音、角色,(角色其实就是由N个造型+N个声音组合而成的,Scratch中的角色数据文件sprite.json实在是太累赘了,后面有空可以贴点这方面的数据结构分析方面的内容,这样就可以知道哪些数据有用了,Scratch的历史积累,有很多数据段,是完全没有意义也没用到的)。

后续再慢慢把造型、声音、角色这方面的后台管理功能也放上来吧,大体上与背景管理差不多。

想提前了解的,下载开源版本,就都有了。。。 。。。

写在后面:

如果本文章对您有帮助,请不吝点个赞再走(点赞不要钱,只管拼命赞)!!!

您的支持,就是本人继续分享的源动力,后续内容更加硬核+精彩,请 收藏+关注 ,方便您及时看到更新的内容!!!

Bailee 了个Bye!!!

  • 18
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值